diff --git a/README.md b/README.md index 482fe96..2e5a7c2 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ ## Current posture - Primary stack: Rust + `clap` -- Operator surfaces: CLI first, TUI inspection plus confirmed selected-thread workflow, draft, and cleanup actions second +- Operator surfaces: CLI first, TUI inspection plus confirmed selected-thread + workflow, draft, cleanup, and persisted automation actions second - Local operational store: SQLite with migration-owned schema and FTS5-backed mailbox search - Native Gmail foundation: OAuth login, active account persistence, live profile/label reads, one-shot mailbox sync, local search, thread-scoped workflow state, remote draft sync, reviewed cleanup actions, attachment catalog/export foundation, review-first automation rules, and a terminal operator shell - Hardening surface: read-only label audits, readiness verification, and operator runbooks for safe real-mailbox rollout @@ -171,9 +172,10 @@ with the durable design captured in The terminal operator shell lives in [`docs/operations/tui-operator-shell.md`](docs/operations/tui-operator-shell.md), with the durable design captured in -[`docs/decisions/0008-read-only-tui-foundation.md`](docs/decisions/0008-read-only-tui-foundation.md) +[`docs/decisions/0008-read-only-tui-foundation.md`](docs/decisions/0008-read-only-tui-foundation.md), [`docs/decisions/0009-tui-workflow-actions.md`](docs/decisions/0009-tui-workflow-actions.md), -and [`docs/decisions/0010-tui-draft-cleanup-flows.md`](docs/decisions/0010-tui-draft-cleanup-flows.md). +[`docs/decisions/0010-tui-draft-cleanup-flows.md`](docs/decisions/0010-tui-draft-cleanup-flows.md), +and [`docs/decisions/0011-tui-automation-apply-rules.md`](docs/decisions/0011-tui-automation-apply-rules.md). Config precedence is: @@ -218,6 +220,8 @@ Advanced manual overrides still work: - [`docs/decisions/0007-verification-audit-hardening.md`](docs/decisions/0007-verification-audit-hardening.md): read-only audit ownership and real-mailbox rollout posture - [`docs/decisions/0008-read-only-tui-foundation.md`](docs/decisions/0008-read-only-tui-foundation.md): read-only terminal shell ownership - [`docs/decisions/0009-tui-workflow-actions.md`](docs/decisions/0009-tui-workflow-actions.md): confirmed local workflow actions in the TUI +- [`docs/decisions/0010-tui-draft-cleanup-flows.md`](docs/decisions/0010-tui-draft-cleanup-flows.md): selected-thread draft and cleanup flows in the TUI +- [`docs/decisions/0011-tui-automation-apply-rules.md`](docs/decisions/0011-tui-automation-apply-rules.md): persisted automation run apply and editor-backed rules editing in the TUI - [`docs/operations/local-config-and-store.md`](docs/operations/local-config-and-store.md): config precedence, store bootstrapping, and hardening - [`docs/operations/gmail-auth-and-account.md`](docs/operations/gmail-auth-and-account.md): Gmail OAuth flow, credential storage, and account verification - [`docs/operations/mailbox-sync-and-search.md`](docs/operations/mailbox-sync-and-search.md): sync commands, search filters, and cursor behavior @@ -232,6 +236,10 @@ Advanced manual overrides still work: ## Near-term build plan 1. Use the verification and hardening runbook to canonicalize labels, deepen the local audit corpus, and generate disabled starter rules with `automation rules suggest`. -2. Add automation action flows to the TUI with confirmation screens that preserve the persisted review-snapshot safety model. -3. Improve automation ergonomics only after a few low-surprise micro-batch archive/label runs land cleanly. -4. Add unsubscribe assistance only after the deeper sync proves out list-header coverage in the local cache. +2. Use the TUI automation pane for validation, disabled suggestion review, + persisted preview runs, saved-run candidate inspection, and high-friction + saved-run apply. +3. Improve automation ergonomics only after a few low-surprise micro-batch + archive/label runs land cleanly. +4. Add unsubscribe assistance only after the deeper sync proves out list-header + coverage in the local cache. diff --git a/docs/README.md b/docs/README.md index 2baf915..774946c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,14 +20,15 @@ Start here: 8. [`decisions/0008-read-only-tui-foundation.md`](decisions/0008-read-only-tui-foundation.md) 9. [`decisions/0009-tui-workflow-actions.md`](decisions/0009-tui-workflow-actions.md) 10. [`decisions/0010-tui-draft-cleanup-flows.md`](decisions/0010-tui-draft-cleanup-flows.md) -11. [`architecture/system-overview.md`](architecture/system-overview.md) -12. [`operations/local-config-and-store.md`](operations/local-config-and-store.md) -13. [`operations/gmail-auth-and-account.md`](operations/gmail-auth-and-account.md) -14. [`operations/mailbox-sync-and-search.md`](operations/mailbox-sync-and-search.md) -15. [`operations/attachment-catalog-and-export.md`](operations/attachment-catalog-and-export.md) -16. [`operations/thread-workflow-and-cleanup.md`](operations/thread-workflow-and-cleanup.md) -17. [`operations/automation-rules-and-bulk-actions.md`](operations/automation-rules-and-bulk-actions.md) -18. [`operations/verification-and-hardening.md`](operations/verification-and-hardening.md) -19. [`operations/tui-operator-shell.md`](operations/tui-operator-shell.md) -20. [`operations/plugin-assisted-workflows.md`](operations/plugin-assisted-workflows.md) -21. [`roadmap/v1-search-triage-draft-queue.md`](roadmap/v1-search-triage-draft-queue.md) +11. [`decisions/0011-tui-automation-apply-rules.md`](decisions/0011-tui-automation-apply-rules.md) +12. [`architecture/system-overview.md`](architecture/system-overview.md) +13. [`operations/local-config-and-store.md`](operations/local-config-and-store.md) +14. [`operations/gmail-auth-and-account.md`](operations/gmail-auth-and-account.md) +15. [`operations/mailbox-sync-and-search.md`](operations/mailbox-sync-and-search.md) +16. [`operations/attachment-catalog-and-export.md`](operations/attachment-catalog-and-export.md) +17. [`operations/thread-workflow-and-cleanup.md`](operations/thread-workflow-and-cleanup.md) +18. [`operations/automation-rules-and-bulk-actions.md`](operations/automation-rules-and-bulk-actions.md) +19. [`operations/verification-and-hardening.md`](operations/verification-and-hardening.md) +20. [`operations/tui-operator-shell.md`](operations/tui-operator-shell.md) +21. [`operations/plugin-assisted-workflows.md`](operations/plugin-assisted-workflows.md) +22. [`roadmap/v1-search-triage-draft-queue.md`](roadmap/v1-search-triage-draft-queue.md) diff --git a/docs/decisions/0008-read-only-tui-foundation.md b/docs/decisions/0008-read-only-tui-foundation.md index 5539f17..54743b8 100644 --- a/docs/decisions/0008-read-only-tui-foundation.md +++ b/docs/decisions/0008-read-only-tui-foundation.md @@ -28,8 +28,10 @@ Mailroom adds `mailroom tui` as a Ratatui-based, read-only operator shell. a read-only automation rollout report from the existing Rust core. - The Search pane runs local SQLite FTS queries through the mailbox read model without store initialization or migration side effects. -- No TUI view exposes Gmail mutations, draft send, cleanup execution, - attachment export, automation snapshot creation, or automation apply. +- No first-slice TUI view exposed Gmail mutations, draft send, cleanup + execution, attachment export, automation snapshot creation, or automation + apply. Later ADRs add confirmed actions while preserving service ownership and + high-friction mutation gates. - `ratatui` is compiled with default features disabled and only the Crossterm backend enabled to avoid pulling extra widget/backend surface. diff --git a/docs/decisions/0011-tui-automation-apply-rules.md b/docs/decisions/0011-tui-automation-apply-rules.md new file mode 100644 index 0000000..e3508b1 --- /dev/null +++ b/docs/decisions/0011-tui-automation-apply-rules.md @@ -0,0 +1,72 @@ +# 0011: TUI Automation Apply and Rules Editing + +## Status + +Accepted. + +## Context + +Mailroom already has a review-first automation model: + +- `.mailroom/automation.toml` is the local typed rules file. +- `automation rollout` is read-only readiness and candidate preview. +- `automation run` persists a frozen review snapshot in SQLite. +- `automation apply --execute` mutates Gmail from that saved snapshot. + +The TUI previously showed only rollout readiness. Issue #26 adds operator flows +for rules review, persisted run creation, saved candidate inspection, and +guarded apply without creating a second rules engine or bypassing the CLI safety +model. + +## Decision + +The TUI automation pane remains a thin operator surface over existing automation +services. + +- Rules validation calls the same rules validation service as + `automation rules validate`. +- Starter suggestions call the same suggestion service as + `automation rules suggest` and remain disabled snippets for operator review. +- Persisted preview creation calls `automation run` semantics and writes a local + review snapshot only. +- Candidate inspection loads a saved run by ID and renders saved candidate + details from SQLite. +- Apply is only available for a loaded persisted run and requires typing + `APPLY` exactly. +- The TUI never applies live `automation rollout` output. + +Rules editing uses `$VISUAL` or `$EDITOR` against `.mailroom/automation.toml` +instead of a constrained in-TUI TOML form. If the active rules file is missing, +the TUI seeds it from `config/automation.example.toml` before opening the +editor. After the editor exits, the TUI validates the file and refreshes the +automation rollout report. + +## Consequences + +- Operators can complete the automation review loop without leaving the TUI for + routine validation, suggestion review, snapshot creation, run inspection, and + guarded apply. +- Operators still use their normal editor for TOML, preserving comments, + formatting, and multi-line edits without adding a second TOML writer. +- Terminal lifecycle must temporarily restore the terminal before launching the + editor, then re-enter Ratatui after the editor exits. +- Gmail mutation risk stays bounded because apply targets only a persisted run + snapshot and requires exact high-friction confirmation. + +## Rejected Options + +### In-TUI Structured Rules Form + +Rejected for this slice. A constrained form would need to represent the full +rules schema, ordering, comments, labels, and future predicates. That duplicates +rules ownership and increases the chance of silently rewriting operator TOML. + +### Apply Rollout Directly + +Rejected. Rollout output is a live preview, not a reviewed snapshot. Applying it +would violate the persisted review boundary from ADR 0006. + +### TUI-Specific Automation Store + +Rejected. Saved runs, candidates, and events already live in the canonical +SQLite automation tables. A TUI-specific store would create drift. diff --git a/docs/operations/automation-rules-and-bulk-actions.md b/docs/operations/automation-rules-and-bulk-actions.md index 80a38fb..3a8e427 100644 --- a/docs/operations/automation-rules-and-bulk-actions.md +++ b/docs/operations/automation-rules-and-bulk-actions.md @@ -169,6 +169,26 @@ cargo run -- automation apply 42 --execute --json Without `--execute`, `automation apply` returns a validation-style error and does not mutate Gmail. +## TUI Automation Operator Flow + +`cargo run -- tui` exposes the same review-first automation loop in the +Automation pane: + +- `v` validates `.mailroom/automation.toml`. +- `g` reviews disabled starter suggestions from local mailbox evidence. +- `e` opens `.mailroom/automation.toml` in `$VISUAL`, `$EDITOR`, or `vi`; if the + file is missing, the TUI seeds it from `config/automation.example.toml` before + launching the editor and validates it after the editor exits. +- `n` creates a persisted preview run snapshot with a positive candidate limit. +- `o` loads a persisted run by ID for candidate inspection. +- `j` / `k` moves through loaded run candidates. +- `a` applies only the loaded persisted run after typing `APPLY` exactly. + +The TUI never applies live `automation rollout` output. It must have a saved run +loaded before apply can open, and the apply confirmation shows the run ID, +candidate count, action mix, any currently blocked rollout rules, and a Gmail +mutation warning. + Prune stale local review snapshots after inspecting the dry-run counts: ```bash diff --git a/docs/operations/tui-operator-shell.md b/docs/operations/tui-operator-shell.md index c43be36..948eae3 100644 --- a/docs/operations/tui-operator-shell.md +++ b/docs/operations/tui-operator-shell.md @@ -2,10 +2,10 @@ `mailroom tui` opens the native terminal operator shell. -It is designed for fast inspection and deliberate workflow, draft, and cleanup -actions after `workspace init`, auth setup, and a local sync. It does not -replace the CLI JSON contract; it renders the same underlying reports for human -operation and uses existing service owners for every action. +It is designed for fast inspection and deliberate workflow, draft, cleanup, and +automation actions after `workspace init`, auth setup, and a local sync. It does +not replace the CLI JSON contract; it renders the same underlying reports for +human operation and uses existing service owners for every action. ## Run @@ -27,7 +27,9 @@ cargo run -- tui --search "project alpha" - Workflows: `workflow list` queue overview, selected-row detail, current draft inspection, confirmed local workflow actions, Gmail draft actions, and cleanup preview/execute flows. -- Automation: read-only `automation rollout` readiness and candidate preview. +- Automation: `automation rollout` readiness, rule validation, disabled starter + suggestion review, persisted run creation, saved-run candidate inspection, and + guarded saved-run apply. - Help: key bindings and safety posture. ## Keys @@ -59,6 +61,17 @@ Workflow view keys: - `l`: preview or execute label cleanup - `x`: preview or execute trash cleanup +Automation view keys: + +- `j` / `Down`: select next saved-run candidate after a run is loaded +- `k` / `Up`: select previous saved-run candidate after a run is loaded +- `v`: validate `.mailroom/automation.toml` +- `g`: review disabled starter suggestions from local mailbox evidence +- `n`: create a persisted automation preview run snapshot +- `o`: load a persisted automation run by ID +- `a`: apply the loaded persisted run after high-friction confirmation +- `e`: open `.mailroom/automation.toml` in `$VISUAL`, `$EDITOR`, or `vi` + Workflow confirmation keys: - `Enter`: confirm the displayed action @@ -79,6 +92,15 @@ Workflow confirmation keys: - label cleanup input: labels are comma-separated; `Tab` / `Shift+Tab` switches between add, remove, and confirmation fields +Automation confirmation keys: + +- `Enter`: confirm the displayed automation action +- `Esc` / `q`: cancel the automation confirmation +- run creation input: type a positive candidate limit; this creates only a + local persisted review snapshot +- saved-run input: type a positive run ID to inspect candidates +- apply input: type `APPLY` exactly, then `Enter` + ## Safety Contract The TUI shell is still review-first. It exposes workflow, draft, and cleanup @@ -107,21 +129,42 @@ Draft send and cleanup execute are high-friction Gmail mutations: - after successful actions, the TUI refreshes the workflow list, selected draft detail, and any active local search report through existing services +Automation actions use the existing automation service layer: + +- rules validation uses `automation rules validate` +- starter suggestions use `automation rules suggest` and stay disabled snippets + for operator review +- run creation uses `automation run` and writes a local persisted snapshot +- saved-run inspection uses `automation show` +- apply uses `automation apply --execute` only for the loaded persisted + run + +Automation apply is a high-friction Gmail mutation: + +- the TUI never applies live `automation rollout` output +- a run must be loaded before apply can open +- the confirmation summarizes the run ID, candidate count, action mix, blocked + rollout rules when present, and a Gmail mutation warning +- apply requires typing `APPLY` exactly before `Enter` +- after successful automation actions, the TUI refreshes rollout readiness + +Rules editing uses the operator's editor instead of an in-TUI TOML form. Press +`e` to open `.mailroom/automation.toml` in `$VISUAL`, `$EDITOR`, or `vi`. If the +rules file is missing, the TUI seeds it from `config/automation.example.toml`. +When the editor exits, the TUI validates the file and refreshes automation +readiness. + It still does not: - promote workflows to `closed` -- apply automation snapshots -- create automation run snapshots - fetch or export attachments -- edit `.mailroom/automation.toml` +- apply rollout previews directly Use the existing CLI commands for flows that remain intentionally outside this TUI slice: ```bash cargo run -- workflow promote --to closed --json -cargo run -- automation run --limit 10 --json -cargo run -- automation apply --execute --json ``` ## Troubleshooting diff --git a/docs/roadmap/v1-search-triage-draft-queue.md b/docs/roadmap/v1-search-triage-draft-queue.md index 2474723..bf53276 100644 --- a/docs/roadmap/v1-search-triage-draft-queue.md +++ b/docs/roadmap/v1-search-triage-draft-queue.md @@ -88,12 +88,16 @@ draft/cleanup slice are now in place too: through existing workflow services - cleanup archive, label, and trash preview by default, with high-friction execute confirmations through existing workflow services -- no attachment export, automation apply, rules editing, or direct Gmail adapter - calls from TUI code - -The next implementation slices should focus on automation action flows and the -real personal ruleset rollout on top of the shipped audit surface, not re-open -auth, account, config, store, sync, workflow, draft, cleanup, attachment, or +- automation rules validation, disabled starter suggestion review, editor-backed + rules editing, persisted run creation, saved-run candidate inspection, and + high-friction saved-run apply through existing automation services +- no attachment export, live rollout apply, or direct Gmail adapter calls from + TUI code + +The next implementation slice should focus on production TUI polish, +accessibility, narrow-terminal resilience, render-path performance, and the real +personal ruleset rollout on top of the shipped audit surface, not re-open auth, +account, config, store, sync, workflow, draft, cleanup, attachment, or automation ownership. ## Deferred diff --git a/src/automation/mod.rs b/src/automation/mod.rs index d32396c..ada712b 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -5,7 +5,10 @@ mod rules; mod service; mod suggestions; -pub(crate) use model::AutomationRolloutReport; +pub(crate) use model::{ + AutomationApplyReport, AutomationRolloutReport, AutomationRulesSuggestReport, + AutomationRulesValidateReport, AutomationRunPreviewReport, AutomationShowReport, +}; pub use model::{ AutomationPruneRequest, AutomationPruneStatus, AutomationRolloutRequest, AutomationRulesSuggestRequest, AutomationRunRequest, DEFAULT_AUTOMATION_ROLLOUT_LIMIT, diff --git a/src/handlers/tui.rs b/src/handlers/tui.rs index 9f05987..9da3e70 100644 --- a/src/handlers/tui.rs +++ b/src/handlers/tui.rs @@ -1,5 +1,5 @@ use crate::cli::TuiArgs; -use crate::{config, tui, workspace}; +use crate::{config, configured_paths, tui, workspace}; use anyhow::Result; use tokio::task::spawn_blocking; @@ -9,5 +9,6 @@ pub(crate) async fn handle_tui_command( ) -> Result<()> { let resolve_paths = paths.clone(); let config_report = spawn_blocking(move || config::resolve(&resolve_paths)).await??; - tui::run(paths, config_report, args.search).await + let paths = configured_paths(&config_report)?; + tui::run(&paths, config_report, args.search).await } diff --git a/src/tui.rs b/src/tui.rs index b4abdb4..a434da3 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,4 +1,9 @@ -use crate::automation::{self, AutomationRolloutRequest, DEFAULT_AUTOMATION_ROLLOUT_LIMIT}; +use crate::automation::{ + self, AutomationRolloutRequest, AutomationRulesSuggestRequest, AutomationRunRequest, + DEFAULT_AUTOMATION_ROLLOUT_LIMIT, DEFAULT_AUTOMATION_RUN_LIMIT, + DEFAULT_AUTOMATION_SUGGESTION_LIMIT, DEFAULT_AUTOMATION_SUGGESTION_MIN_THREAD_COUNT, + DEFAULT_AUTOMATION_SUGGESTION_OLDER_THAN_DAYS, DEFAULT_AUTOMATION_SUGGESTION_SAMPLE_LIMIT, +}; use crate::config::ConfigReport; use crate::doctor::DoctorReport; use crate::mailbox::{self, SearchReport, SearchRequest}; @@ -12,6 +17,8 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Tabs, Wrap}; +use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::Duration; use tokio::task::spawn_blocking; @@ -54,6 +61,14 @@ pub async fn run( if handle_key(key, &mut app, paths, &config_report).await? { break; } + if let Some(action) = app.pending_terminal_action.take() { + ratatui::restore(); + let result = run_terminal_action(action, paths); + terminal = ratatui::try_init()?; + terminal.clear()?; + app.handle_terminal_action_result(paths, &config_report, result) + .await; + } } } @@ -81,6 +96,13 @@ struct TuiApp { workflow_modal: Option, workflow_detail_report: Option>, workflow_action_report: Option>, + automation_modal: Option, + automation_detail_report: Option>, + automation_action_report: Option>, + automation_candidate_selection: usize, + automation_candidate_scroll: usize, + automation_candidate_window_limit: usize, + pending_terminal_action: Option, status: String, } @@ -104,6 +126,13 @@ impl TuiApp { workflow_modal: None, workflow_detail_report: None, workflow_action_report: None, + automation_modal: None, + automation_detail_report: None, + automation_action_report: None, + automation_candidate_selection: 0, + automation_candidate_scroll: 0, + automation_candidate_window_limit: TUI_WORKFLOW_FALLBACK_WINDOW_LIMIT, + pending_terminal_action: None, status: String::from("TUI ready: local workflow actions require explicit confirmation"), } } @@ -153,6 +182,7 @@ impl TuiApp { self.view = VIEWS[next]; self.search_editing = false; self.workflow_modal = None; + self.automation_modal = None; } fn previous_view(&mut self) { @@ -160,6 +190,7 @@ impl TuiApp { self.view = VIEWS[previous]; self.search_editing = false; self.workflow_modal = None; + self.automation_modal = None; } fn workflow_rows(&self) -> &[store::workflows::WorkflowRecord] { @@ -532,6 +563,402 @@ impl TuiApp { } self.normalize_workflow_selection(); } + + fn loaded_automation_detail(&self) -> Option<&automation::AutomationShowReport> { + self.automation_detail_report + .as_ref() + .and_then(|result| result.as_ref().ok()) + } + + fn loaded_automation_run_id(&self) -> Option { + self.loaded_automation_detail() + .map(|report| report.detail.run.run_id) + } + + fn visible_automation_candidates( + &self, + ) -> impl Iterator { + self.loaded_automation_detail() + .map(|report| report.detail.candidates.as_slice()) + .unwrap_or(&[]) + .iter() + .enumerate() + .skip(self.automation_candidate_scroll) + .take(self.automation_candidate_window_limit) + } + + fn selected_automation_candidate( + &self, + ) -> Option<&store::automation::AutomationRunCandidateRecord> { + self.loaded_automation_detail().and_then(|report| { + report + .detail + .candidates + .get(self.automation_candidate_selection) + }) + } + + fn normalize_automation_candidate_selection(&mut self) { + let candidate_count = self + .loaded_automation_detail() + .map(|report| report.detail.candidates.len()) + .unwrap_or_default(); + if candidate_count == 0 { + self.automation_candidate_selection = 0; + self.automation_candidate_scroll = 0; + } else if self.automation_candidate_selection >= candidate_count { + self.automation_candidate_selection = candidate_count - 1; + } + self.ensure_automation_candidate_selection_visible(); + } + + fn ensure_automation_candidate_selection_visible(&mut self) { + let window_limit = self.automation_candidate_window_limit.max(1); + let candidate_count = self + .loaded_automation_detail() + .map(|report| report.detail.candidates.len()) + .unwrap_or_default(); + if candidate_count <= window_limit { + self.automation_candidate_scroll = 0; + return; + } + + if self.automation_candidate_selection < self.automation_candidate_scroll { + self.automation_candidate_scroll = self.automation_candidate_selection; + } else if self.automation_candidate_selection + >= self.automation_candidate_scroll + window_limit + { + self.automation_candidate_scroll = + self.automation_candidate_selection + 1 - window_limit; + } + } + + fn set_automation_candidate_window_limit(&mut self, window_limit: usize) { + self.automation_candidate_window_limit = window_limit.max(1); + self.ensure_automation_candidate_selection_visible(); + } + + fn select_next_automation_candidate(&mut self) { + let candidate_count = self + .loaded_automation_detail() + .map(|report| report.detail.candidates.len()) + .unwrap_or_default(); + if candidate_count == 0 { + self.automation_candidate_selection = 0; + self.automation_candidate_scroll = 0; + return; + } + self.automation_candidate_selection = + (self.automation_candidate_selection + 1) % candidate_count; + self.ensure_automation_candidate_selection_visible(); + } + + fn select_previous_automation_candidate(&mut self) { + let candidate_count = self + .loaded_automation_detail() + .map(|report| report.detail.candidates.len()) + .unwrap_or_default(); + if candidate_count == 0 { + self.automation_candidate_selection = 0; + self.automation_candidate_scroll = 0; + return; + } + self.automation_candidate_selection = self + .automation_candidate_selection + .checked_sub(1) + .unwrap_or(candidate_count - 1); + self.ensure_automation_candidate_selection_visible(); + } + + async fn refresh_automation(&mut self, config_report: &ConfigReport) { + self.snapshot.automation = automation::rollout_read_only( + config_report, + AutomationRolloutRequest { + rule_ids: Vec::new(), + limit: DEFAULT_AUTOMATION_ROLLOUT_LIMIT, + }, + ) + .await + .map_err(|error| error_chain(&error)); + } + + async fn validate_automation_rules(&mut self, config_report: &ConfigReport) { + match automation::validate_rules(config_report).await { + Ok(report) => { + let enabled = report.enabled_rule_count; + let total = report.rule_count; + self.automation_action_report = + Some(Ok(AutomationActionReport::RulesValidate(report))); + self.refresh_automation(config_report).await; + self.status = format!("automation rules valid: {enabled}/{total} enabled"); + } + Err(error) => { + self.automation_action_report = Some(Err(error.to_string())); + self.refresh_automation(config_report).await; + self.status = String::from("automation rules validation failed"); + } + } + } + + async fn suggest_automation_rules(&mut self, config_report: &ConfigReport) { + let request = AutomationRulesSuggestRequest { + limit: DEFAULT_AUTOMATION_SUGGESTION_LIMIT, + min_thread_count: DEFAULT_AUTOMATION_SUGGESTION_MIN_THREAD_COUNT, + older_than_days: DEFAULT_AUTOMATION_SUGGESTION_OLDER_THAN_DAYS, + sample_limit: DEFAULT_AUTOMATION_SUGGESTION_SAMPLE_LIMIT, + }; + match automation::suggest_rules(config_report, request).await { + Ok(report) => { + let count = report.suggestion_count; + self.automation_action_report = + Some(Ok(AutomationActionReport::RulesSuggest(Box::new(report)))); + self.refresh_automation(config_report).await; + self.status = format!("automation suggestions ready: {count} disabled starters"); + } + Err(error) => { + self.automation_action_report = Some(Err(error.to_string())); + self.refresh_automation(config_report).await; + self.status = String::from("automation rules suggestion failed"); + } + } + } + + fn open_automation_run_modal(&mut self) { + self.automation_modal = Some(AutomationModal::RunPreview { + limit_text: DEFAULT_AUTOMATION_RUN_LIMIT.to_string(), + }); + self.status = String::from("automation run preview confirmation active"); + } + + fn open_automation_show_modal(&mut self) { + let run_id_text = self + .loaded_automation_run_id() + .map(|run_id| run_id.to_string()) + .unwrap_or_default(); + self.automation_modal = Some(AutomationModal::ShowRun { run_id_text }); + self.status = String::from("automation run id input active"); + } + + fn open_automation_apply_modal(&mut self) { + let Some(report) = self.loaded_automation_detail() else { + self.status = String::from("automation apply unavailable: load a persisted run first"); + return; + }; + if report.detail.run.status != store::automation::AutomationRunStatus::Previewed { + self.status = format!( + "automation apply unavailable: loaded run is {}", + report.detail.run.status + ); + return; + } + self.automation_modal = Some(AutomationModal::ApplyRun { + run_id: report.detail.run.run_id, + confirm_text: String::new(), + }); + self.status = String::from("automation apply confirmation active; type APPLY to execute"); + } + + fn queue_automation_rules_editor(&mut self) { + self.pending_terminal_action = Some(TerminalAction::EditAutomationRules); + self.status = String::from("opening $EDITOR for .mailroom/automation.toml"); + } + + async fn confirm_automation_modal(&mut self, config_report: &ConfigReport) { + if let Some(AutomationModal::ApplyRun { confirm_text, .. }) = &self.automation_modal + && confirm_text != "APPLY" + { + self.automation_action_report = Some(Err(String::from( + "automation apply requires typing APPLY before Enter", + ))); + self.status = String::from("automation apply blocked: type APPLY before Enter"); + return; + } + + let Some(modal) = self.automation_modal.take() else { + return; + }; + match modal { + AutomationModal::RunPreview { limit_text } => { + let Some(limit) = parse_positive_usize(&limit_text) else { + self.automation_modal = Some(AutomationModal::RunPreview { limit_text }); + self.automation_action_report = Some(Err(String::from( + "automation run limit must be a positive integer", + ))); + self.status = String::from("automation run blocked: invalid limit"); + return; + }; + let request = AutomationRunRequest { + rule_ids: Vec::new(), + limit, + }; + match automation::run_preview(config_report, request).await { + Ok(report) => { + let run_id = report.detail.run.run_id; + let candidate_count = report.detail.candidates.len(); + self.automation_detail_report = + Some(Ok(automation::AutomationShowReport { + detail: report.detail.clone(), + })); + self.automation_action_report = + Some(Ok(AutomationActionReport::RunPreview(Box::new(report)))); + self.automation_candidate_selection = 0; + self.normalize_automation_candidate_selection(); + self.refresh_automation(config_report).await; + self.status = format!( + "automation run {run_id} persisted with {candidate_count} candidates" + ); + } + Err(error) => { + self.automation_action_report = Some(Err(error.to_string())); + self.refresh_automation(config_report).await; + self.status = String::from("automation run creation failed"); + } + } + } + AutomationModal::ShowRun { run_id_text } => { + let Some(run_id) = parse_positive_i64(&run_id_text) else { + self.automation_modal = Some(AutomationModal::ShowRun { run_id_text }); + self.automation_action_report = Some(Err(String::from( + "automation run id must be a positive integer", + ))); + self.status = String::from("automation show blocked: invalid run id"); + return; + }; + match automation::show_run(config_report, run_id).await { + Ok(report) => { + let candidate_count = report.detail.candidates.len(); + self.automation_detail_report = Some(Ok(report)); + self.automation_candidate_selection = 0; + self.normalize_automation_candidate_selection(); + self.status = format!( + "automation run {run_id} loaded with {candidate_count} candidates" + ); + } + Err(error) => { + self.automation_detail_report = Some(Err(error.to_string())); + self.refresh_automation(config_report).await; + self.status = String::from("automation run load failed"); + } + } + } + AutomationModal::ApplyRun { run_id, .. } => { + match automation::apply_run(config_report, run_id, true).await { + Ok(report) => { + let applied = report.applied_candidate_count; + let failed = report.failed_candidate_count; + self.automation_detail_report = + Some(Ok(automation::AutomationShowReport { + detail: report.detail.clone(), + })); + self.automation_action_report = + Some(Ok(AutomationActionReport::Apply(Box::new(report)))); + self.automation_candidate_selection = 0; + self.normalize_automation_candidate_selection(); + self.refresh_automation(config_report).await; + self.status = format!( + "automation run {run_id} applied: {applied} succeeded, {failed} failed" + ); + } + Err(error) => { + self.automation_action_report = Some(Err(error.to_string())); + self.refresh_automation(config_report).await; + self.status = String::from("automation apply failed"); + } + } + } + } + } + + async fn handle_terminal_action_result( + &mut self, + _paths: &workspace::WorkspacePaths, + config_report: &ConfigReport, + result: std::result::Result, + ) { + match result { + Ok(TerminalActionReport::AutomationRulesEdited { path }) => { + match automation::validate_rules(config_report).await { + Ok(report) => { + let enabled = report.enabled_rule_count; + let total = report.rule_count; + self.automation_action_report = + Some(Ok(AutomationActionReport::RulesValidate(report))); + self.refresh_automation(config_report).await; + self.status = + format!("automation rules edited and valid: {enabled}/{total} enabled"); + } + Err(error) => { + self.automation_action_report = Some(Err(format!( + "edited rules file {} did not validate: {error}", + path.display() + ))); + self.refresh_automation(config_report).await; + self.status = String::from("automation rules edit did not validate"); + } + } + } + Err(error) => { + self.automation_action_report = Some(Err(error)); + self.refresh_automation(config_report).await; + self.status = String::from("automation rules editor failed"); + } + } + } +} + +#[derive(Debug, Clone)] +enum AutomationActionReport { + RulesValidate(automation::AutomationRulesValidateReport), + RulesSuggest(Box), + RunPreview(Box), + Apply(Box), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum AutomationModal { + RunPreview { limit_text: String }, + ShowRun { run_id_text: String }, + ApplyRun { run_id: i64, confirm_text: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TerminalAction { + EditAutomationRules, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TerminalActionReport { + AutomationRulesEdited { path: PathBuf }, +} + +impl AutomationModal { + fn title(&self) -> &'static str { + match self { + Self::RunPreview { .. } => "Create automation review snapshot", + Self::ShowRun { .. } => "Load automation run", + Self::ApplyRun { .. } => "Confirm automation apply", + } + } + + fn action_summary(&self) -> String { + match self { + Self::RunPreview { limit_text } => { + format!( + "persist preview run from enabled rules, limit {}", + limit_text.trim() + ) + } + Self::ShowRun { run_id_text } => { + format!( + "load persisted automation run {}", + blank_label_summary(run_id_text) + ) + } + Self::ApplyRun { run_id, .. } => { + format!("apply persisted automation run {run_id}") + } + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -822,6 +1249,229 @@ fn pop_workflow_modal_text(modal: &mut WorkflowModal) { } } +fn automation_modal_uses_text_input(modal: &AutomationModal) -> bool { + match modal { + AutomationModal::RunPreview { .. } + | AutomationModal::ShowRun { .. } + | AutomationModal::ApplyRun { .. } => true, + } +} + +fn push_automation_modal_text(modal: &mut AutomationModal, value: char) { + match modal { + AutomationModal::RunPreview { limit_text } => limit_text.push(value), + AutomationModal::ShowRun { run_id_text } => run_id_text.push(value), + AutomationModal::ApplyRun { confirm_text, .. } => confirm_text.push(value), + } +} + +fn pop_automation_modal_text(modal: &mut AutomationModal) { + match modal { + AutomationModal::RunPreview { limit_text } => { + limit_text.pop(); + } + AutomationModal::ShowRun { run_id_text } => { + run_id_text.pop(); + } + AutomationModal::ApplyRun { confirm_text, .. } => { + confirm_text.pop(); + } + } +} + +fn parse_positive_usize(value: &str) -> Option { + value + .trim() + .parse::() + .ok() + .filter(|value| *value > 0) +} + +fn parse_positive_i64(value: &str) -> Option { + value.trim().parse::().ok().filter(|value| *value > 0) +} + +fn run_terminal_action( + action: TerminalAction, + paths: &workspace::WorkspacePaths, +) -> std::result::Result { + match action { + TerminalAction::EditAutomationRules => edit_automation_rules_blocking(paths), + } +} + +fn edit_automation_rules_blocking( + paths: &workspace::WorkspacePaths, +) -> std::result::Result { + let rules_path = ensure_automation_rules_file(paths)?; + + let editor = editor_command_from_env()?; + let status = Command::new(&editor.program) + .args(&editor.args) + .arg(&rules_path) + .status() + .map_err(|error| { + format!( + "failed to launch editor {} for {}: {error}", + editor.program, + rules_path.display() + ) + })?; + if !status.success() { + return Err(format!( + "editor exited unsuccessfully for {}: {status}", + rules_path.display() + )); + } + Ok(TerminalActionReport::AutomationRulesEdited { path: rules_path }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EditorCommand { + program: String, + args: Vec, +} + +fn editor_command_from_env() -> std::result::Result { + for variable in ["VISUAL", "EDITOR"] { + if let Ok(value) = std::env::var(variable) + && !value.trim().is_empty() + { + return parse_editor_command(&value) + .map_err(|error| format!("invalid ${variable} editor command: {error}")); + } + } + Ok(EditorCommand { + program: default_editor_program().to_string(), + args: Vec::new(), + }) +} + +#[cfg(windows)] +fn default_editor_program() -> &'static str { + "notepad" +} + +#[cfg(not(windows))] +fn default_editor_program() -> &'static str { + "vi" +} + +fn parse_editor_command(value: &str) -> std::result::Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(String::from("editor command is empty")); + } + if Path::new(trimmed).exists() { + return Ok(EditorCommand { + program: trimmed.to_string(), + args: Vec::new(), + }); + } + let mut words = split_command_words(trimmed)?; + if words.is_empty() { + return Err(String::from("editor command is empty")); + } + let program = words.remove(0); + Ok(EditorCommand { + program, + args: words, + }) +} + +fn split_command_words(value: &str) -> std::result::Result, String> { + let mut words = Vec::new(); + let mut current = String::new(); + let mut quote: Option = None; + let mut escaped = false; + + for value in value.chars() { + if escaped { + current.push(value); + escaped = false; + continue; + } + if value == '\\' { + escaped = true; + continue; + } + if let Some(quote_char) = quote { + if value == quote_char { + quote = None; + } else { + current.push(value); + } + continue; + } + if value == '\'' || value == '"' { + quote = Some(value); + continue; + } + if value.is_whitespace() { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + continue; + } + current.push(value); + } + + if escaped { + current.push('\\'); + } + if quote.is_some() { + return Err(String::from("unterminated quote")); + } + if !current.is_empty() { + words.push(current); + } + Ok(words) +} + +fn ensure_automation_rules_file( + paths: &workspace::WorkspacePaths, +) -> std::result::Result { + paths + .ensure_runtime_dirs() + .map_err(|error| format!("failed to create runtime directories: {error}"))?; + let rules_path = paths.runtime_root.join("automation.toml"); + if !rules_path.exists() { + seed_automation_rules_file(paths, &rules_path)?; + } + Ok(rules_path) +} + +fn seed_automation_rules_file( + paths: &workspace::WorkspacePaths, + rules_path: &Path, +) -> std::result::Result<(), String> { + let parent = rules_path + .parent() + .ok_or_else(|| format!("rules path has no parent: {}", rules_path.display()))?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + let template_path = paths + .repo_root + .join("config") + .join("automation.example.toml"); + if template_path.exists() { + std::fs::copy(&template_path, rules_path).map_err(|error| { + format!( + "failed to seed {} from {}: {error}", + rules_path.display(), + template_path.display() + ) + })?; + } else { + std::fs::write( + rules_path, + "# Mailroom automation rules. Add [[rules]] entries here.\nrules = []\n", + ) + .map_err(|error| format!("failed to write {}: {error}", rules_path.display()))?; + } + Ok(()) +} + #[derive(Debug, Clone)] struct Snapshot { doctor: std::result::Result, @@ -926,6 +1576,10 @@ async fn handle_key( return handle_workflow_modal_key(key, app, paths, config_report).await; } + if app.automation_modal.is_some() { + return handle_automation_modal_key(key, app, config_report).await; + } + if app.search_editing { return handle_search_key(key, app, config_report).await; } @@ -984,6 +1638,44 @@ async fn handle_key( } } + if app.view == View::Automation { + match key.code { + KeyCode::Down | KeyCode::Char('j') => { + app.select_next_automation_candidate(); + return Ok(false); + } + KeyCode::Up | KeyCode::Char('k') => { + app.select_previous_automation_candidate(); + return Ok(false); + } + KeyCode::Char('v') => { + app.validate_automation_rules(config_report).await; + return Ok(false); + } + KeyCode::Char('g') => { + app.suggest_automation_rules(config_report).await; + return Ok(false); + } + KeyCode::Char('n') => { + app.open_automation_run_modal(); + return Ok(false); + } + KeyCode::Char('o') => { + app.open_automation_show_modal(); + return Ok(false); + } + KeyCode::Char('a') => { + app.open_automation_apply_modal(); + return Ok(false); + } + KeyCode::Char('e') => { + app.queue_automation_rules_editor(); + return Ok(false); + } + _ => {} + } + } + match key.code { KeyCode::Char('q') | KeyCode::Esc => Ok(true), KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { @@ -1131,21 +1823,53 @@ async fn handle_workflow_modal_key( Ok(false) } -async fn handle_search_key( +async fn handle_automation_modal_key( key: KeyEvent, app: &mut TuiApp, config_report: &ConfigReport, ) -> AnyhowResult { match key.code { - KeyCode::Esc => { - app.search_editing = false; - app.status = String::from("search input inactive"); - Ok(false) + KeyCode::Esc | KeyCode::Char('q') => { + app.automation_modal = None; + app.status = String::from("automation action canceled"); } - KeyCode::Enter => { - app.search_editing = false; - app.submit_search(config_report).await; - Ok(false) + KeyCode::Enter => app.confirm_automation_modal(config_report).await, + KeyCode::Backspace => { + if let Some(modal) = &mut app.automation_modal { + pop_automation_modal_text(modal); + } + } + KeyCode::Char(value) + if accepts_plain_text_key(key) + && app + .automation_modal + .as_ref() + .is_some_and(automation_modal_uses_text_input) => + { + if let Some(modal) = &mut app.automation_modal { + push_automation_modal_text(modal, value); + } + } + _ => {} + } + Ok(false) +} + +async fn handle_search_key( + key: KeyEvent, + app: &mut TuiApp, + config_report: &ConfigReport, +) -> AnyhowResult { + match key.code { + KeyCode::Esc => { + app.search_editing = false; + app.status = String::from("search input inactive"); + Ok(false) + } + KeyCode::Enter => { + app.search_editing = false; + app.submit_search(config_report).await; + Ok(false) } KeyCode::Backspace => { app.search_input.pop(); @@ -1184,6 +1908,9 @@ fn render(frame: &mut Frame<'_>, app: &mut TuiApp) { if let Some(modal) = &app.workflow_modal { render_workflow_modal(frame, area, app, modal); } + if let Some(modal) = &app.automation_modal { + render_automation_modal(frame, area, app, modal); + } } fn render_header(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) { @@ -1205,7 +1932,7 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) { fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) { let footer = Paragraph::new(Text::from(vec![Line::from(vec![ Span::raw( - "q quit | tab view | 1-5 jump | / search | r refresh | workflows: j/k t/p/z d/b/s a/l/x | ", + "q quit | tab view | 1-5 jump | / search | r refresh | workflows: j/k t/p/z d/b/s a/l/x | automation: v/g/n/o/a/e | ", ), Span::styled(&app.status, Style::default().fg(Color::Yellow)), ])])); @@ -1692,12 +2419,91 @@ fn workflow_modal_height(modal: &WorkflowModal) -> u16 { } } -fn render_automation(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) { +fn render_automation_modal( + frame: &mut Frame<'_>, + area: Rect, + app: &TuiApp, + modal: &AutomationModal, +) { + let popup = centered_rect(76, automation_modal_height(modal), area); + frame.render_widget(Clear, popup); + let mut lines = Vec::new(); + lines.push(metric("action", modal.action_summary())); + match modal { + AutomationModal::RunPreview { limit_text } => { + lines.push(Line::from( + "Creates a persisted local review snapshot using automation run semantics.", + )); + lines.push(Line::from( + "This does not mutate Gmail; apply remains a separate saved-run action.", + )); + lines.push(metric("limit", limit_text.clone())); + } + AutomationModal::ShowRun { run_id_text } => { + lines.push(Line::from( + "Loads a persisted run snapshot for candidate inspection.", + )); + lines.push(metric("run_id", run_id_text.clone())); + } + AutomationModal::ApplyRun { + run_id, + confirm_text, + } => { + lines.push(Line::from(Span::styled( + "This mutates Gmail using the saved snapshot only. Type APPLY exactly, then Enter.", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ))); + lines.push(metric("run_id", run_id.to_string())); + if let Some(report) = app.loaded_automation_detail() { + lines.push(metric( + "candidate_count", + report.detail.candidates.len().to_string(), + )); + lines.push(metric( + "action_mix", + automation_action_mix(&report.detail).join(", "), + )); + } + if let Ok(rollout) = &app.snapshot.automation + && !rollout.blocked_rule_ids.is_empty() + { + lines.push(metric("blocked_rules", rollout.blocked_rule_ids.join(", "))); + } + lines.push(Line::from(Span::styled( + "Gmail warning: archive, label, or trash operations are applied to real threads.", + Style::default().fg(Color::Red), + ))); + lines.push(metric("confirm", confirm_text.clone())); + } + } + lines.push(Line::default()); + lines.push(Line::from("Enter confirm | Esc/q cancel | Ctrl-C quit")); + frame.render_widget( + Paragraph::new(Text::from(lines)) + .block(Block::default().borders(Borders::ALL).title(modal.title())) + .wrap(Wrap { trim: true }), + popup, + ); +} + +fn automation_modal_height(modal: &AutomationModal) -> u16 { + match modal { + AutomationModal::ApplyRun { .. } => 15, + _ => 10, + } +} + +fn render_automation(frame: &mut Frame<'_>, area: Rect, app: &mut TuiApp) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(8), Constraint::Min(6)]) + .constraints([ + Constraint::Length(8), + Constraint::Length(6), + Constraint::Min(6), + ]) .split(area); + app.set_automation_candidate_window_limit(automation_run_panel_table_capacity(chunks[2])); match &app.snapshot.automation { Ok(report) => { let mut summary = vec![ @@ -1718,38 +2524,285 @@ fn render_automation(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) { chunks[0], ); - let rows = report.candidates.iter().take(50).map(|candidate| { + render_automation_action_panel(frame, chunks[1], app); + render_automation_run_panel(frame, chunks[2], app, report); + } + Err(error) => render_text_panel(frame, area, "Automation", vec![error_line(error)]), + } +} + +fn automation_run_panel_table_capacity(area: Rect) -> usize { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(62), Constraint::Percentage(38)]) + .split(area); + workflow_table_row_capacity(chunks[0]) +} + +fn render_automation_run_panel( + frame: &mut Frame<'_>, + area: Rect, + app: &TuiApp, + rollout: &automation::AutomationRolloutReport, +) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(62), Constraint::Percentage(38)]) + .split(area); + + if let Some(Ok(report)) = &app.automation_detail_report { + let rows = app + .visible_automation_candidates() + .map(|(index, candidate)| { + let marker = if index == app.automation_candidate_selection { + ">" + } else { + " " + }; Row::new(vec![ - Cell::from(truncate(&candidate.rule_id, 24)), - Cell::from(candidate.action_kind.clone()), - Cell::from(truncate(&candidate.subject, 42)), - Cell::from(truncate( - candidate.from_address.as_deref().unwrap_or("-"), - 28, - )), + Cell::from(marker), + Cell::from(candidate.candidate_id.to_string()), + Cell::from(truncate(&candidate.rule_id, 20)), + Cell::from(candidate.action.kind.to_string()), + Cell::from(truncate(&candidate.subject, 36)), ]) }); - let table = Table::new( - rows, - [ - Constraint::Percentage(24), - Constraint::Length(10), - Constraint::Percentage(42), - Constraint::Percentage(24), - ], - ) - .header( - Row::new(vec!["Rule", "Action", "Subject", "From"]) - .style(Style::default().add_modifier(Modifier::BOLD)), - ) - .block( - Block::default() - .borders(Borders::ALL) - .title("Candidate preview"), - ); - frame.render_widget(table, chunks[1]); + let table = Table::new( + rows, + [ + Constraint::Length(1), + Constraint::Length(8), + Constraint::Percentage(26), + Constraint::Length(10), + Constraint::Percentage(50), + ], + ) + .header( + Row::new(vec!["", "ID", "Rule", "Action", "Subject"]) + .style(Style::default().add_modifier(Modifier::BOLD)), + ) + .block(Block::default().borders(Borders::ALL).title(format!( + "Saved run {} ({}, {} candidates)", + report.detail.run.run_id, report.detail.run.status, report.detail.run.candidate_count + ))); + frame.render_widget(table, chunks[0]); + render_automation_candidate_detail(frame, chunks[1], app); + } else { + let rows = rollout.candidates.iter().take(50).map(|candidate| { + Row::new(vec![ + Cell::from(truncate(&candidate.rule_id, 24)), + Cell::from(candidate.action_kind.clone()), + Cell::from(truncate(&candidate.subject, 42)), + Cell::from(truncate( + candidate.from_address.as_deref().unwrap_or("-"), + 28, + )), + ]) + }); + let table = Table::new( + rows, + [ + Constraint::Percentage(24), + Constraint::Length(10), + Constraint::Percentage(42), + Constraint::Percentage(24), + ], + ) + .header( + Row::new(vec!["Rule", "Action", "Subject", "From"]) + .style(Style::default().add_modifier(Modifier::BOLD)), + ) + .block( + Block::default() + .borders(Borders::ALL) + .title("Rollout candidate preview"), + ); + frame.render_widget(table, chunks[0]); + render_text_panel( + frame, + chunks[1], + "Automation keys", + vec![ + Line::from("v validate rules | g suggest disabled starters"), + Line::from("n create persisted preview run | o load run"), + Line::from("j/k move saved-run candidates after load"), + Line::from("a apply loaded run after typing APPLY"), + Line::from("e edit rules in $EDITOR, then validate"), + ], + ); + } +} + +fn render_automation_action_panel(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) { + let mut lines = Vec::new(); + match &app.automation_action_report { + Some(Ok(report)) => render_automation_action_report(&mut lines, report), + Some(Err(error)) => lines.push(error_line(error)), + None => { + lines.push(Line::from( + "Actions: v validate | g suggest | n run snapshot | o show run | a apply loaded run | e edit rules", + )); + lines.push(Line::from( + "Apply always targets a persisted run snapshot; rollout preview is never applied directly.", + )); } - Err(error) => render_text_panel(frame, area, "Automation", vec![error_line(error)]), + } + render_text_panel(frame, area, "Automation action", lines); +} + +fn render_automation_action_report( + lines: &mut Vec>, + report: &AutomationActionReport, +) { + match report { + AutomationActionReport::RulesValidate(report) => { + lines.push(Line::from("rules validation complete")); + lines.push(metric("path", report.path.display().to_string())); + lines.push(metric("rules", report.rule_count.to_string())); + lines.push(metric("enabled", report.enabled_rule_count.to_string())); + let ids = report + .rules + .iter() + .take(4) + .map(|rule| rule.id.as_str()) + .collect::>() + .join(", "); + if !ids.is_empty() { + lines.push(metric("sample", ids)); + } + } + AutomationActionReport::RulesSuggest(report) => { + lines.push(Line::from("disabled starter suggestions ready")); + lines.push(metric( + "rules_path", + report.rules_path.display().to_string(), + )); + lines.push(metric("suggestions", report.suggestion_count.to_string())); + for suggestion in report.suggestions.iter().take(3) { + lines.push(Line::from(format!( + "- {} [{}]: {} threads", + truncate(&suggestion.rule_id, 32), + suggestion.confidence, + suggestion.matched_thread_count + ))); + } + } + AutomationActionReport::RunPreview(report) => { + lines.push(Line::from("persisted preview run created")); + lines.push(metric("run_id", report.detail.run.run_id.to_string())); + lines.push(metric( + "candidates", + report.detail.candidates.len().to_string(), + )); + lines.push(metric( + "actions", + automation_action_mix(&report.detail).join(", "), + )); + } + AutomationActionReport::Apply(report) => { + lines.push(Line::from("persisted run apply complete")); + lines.push(metric("run_id", report.detail.run.run_id.to_string())); + lines.push(metric( + "applied", + report.applied_candidate_count.to_string(), + )); + lines.push(metric("failed", report.failed_candidate_count.to_string())); + } + } +} + +fn render_automation_candidate_detail(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) { + let mut lines = Vec::new(); + if let Some(candidate) = app.selected_automation_candidate() { + lines.push(metric("candidate_id", candidate.candidate_id.to_string())); + lines.push(metric("rule_id", candidate.rule_id.clone())); + lines.push(metric("thread_id", candidate.thread_id.clone())); + lines.push(metric("action", candidate.action.kind.to_string())); + lines.push(metric( + "apply_status", + candidate + .apply_status + .map(|status| status.to_string()) + .unwrap_or_else(|| String::from("pending")), + )); + if let Some(error) = &candidate.apply_error { + lines.push(error_line(error)); + } + lines.push(metric( + "from", + candidate.from_address.clone().unwrap_or_default(), + )); + lines.push(metric("subject", truncate(&candidate.subject, 60))); + lines.push(metric("labels", candidate.label_names.join(", "))); + lines.push(metric( + "matched", + automation_match_summary(&candidate.reason), + )); + } else if let Some(Err(error)) = &app.automation_detail_report { + lines.push(error_line(error)); + } else { + lines.push(Line::from("No persisted run candidate loaded.")); + } + render_text_panel(frame, area, "Candidate detail", lines); +} + +fn automation_action_mix(detail: &store::automation::AutomationRunDetail) -> Vec { + let mut archive = 0usize; + let mut label = 0usize; + let mut trash = 0usize; + for candidate in &detail.candidates { + match candidate.action.kind { + store::automation::AutomationActionKind::Archive => archive += 1, + store::automation::AutomationActionKind::Label => label += 1, + store::automation::AutomationActionKind::Trash => trash += 1, + } + } + let mut parts = Vec::new(); + if archive > 0 { + parts.push(format!("archive={archive}")); + } + if label > 0 { + parts.push(format!("label={label}")); + } + if trash > 0 { + parts.push(format!("trash={trash}")); + } + if parts.is_empty() { + parts.push(String::from("none")); + } + parts +} + +fn automation_match_summary(reason: &store::automation::AutomationMatchReason) -> String { + let mut parts = Vec::new(); + if let Some(from_address) = &reason.from_address { + parts.push(format!("from={from_address}")); + } + if !reason.subject_terms.is_empty() { + parts.push(format!("subject={}", reason.subject_terms.join(","))); + } + if !reason.label_names.is_empty() { + parts.push(format!("labels={}", reason.label_names.join(","))); + } + if let Some(days) = reason.older_than_days { + parts.push(format!("older_than_days={days}")); + } + if let Some(value) = reason.has_attachments { + parts.push(format!("has_attachments={value}")); + } + if let Some(value) = reason.has_list_unsubscribe { + parts.push(format!("has_list_unsubscribe={value}")); + } + if !reason.list_id_terms.is_empty() { + parts.push(format!("list_id={}", reason.list_id_terms.join(","))); + } + if !reason.precedence_values.is_empty() { + parts.push(format!("precedence={}", reason.precedence_values.join(","))); + } + if parts.is_empty() { + String::from("-") + } else { + truncate(&parts.join("; "), 96) } } @@ -1764,13 +2817,17 @@ fn render_help(frame: &mut Frame<'_>, area: Rect) { Line::from("1 Dashboard: auth, store, mailbox, and readiness summary."), Line::from("2 Search: run local SQLite FTS queries against synced mail."), Line::from("3 Workflows: confirm triage/promote/snooze, draft, and cleanup actions."), - Line::from("4 Automation: inspect rollout readiness and preview candidates."), + Line::from("4 Automation: validate rules, persist runs, inspect candidates, apply."), Line::from("5 Help: key bindings and safety posture."), Line::from(""), Line::from("Workflow keys: i inspect draft | d start | b body | s send."), Line::from("Cleanup keys: a archive | l label | x trash; Ctrl-E toggles execute."), Line::from("Draft send requires typing SEND; cleanup execute requires APPLY."), - Line::from("No view applies automation, exports attachments, or edits rules."), + Line::from( + "Automation keys: v validate | g suggest | n run | o show | a apply | e edit.", + ), + Line::from("Automation apply requires a persisted run and typing APPLY."), + Line::from("No view applies live rollout output or exports attachments."), ], ); } @@ -2032,12 +3089,19 @@ fn error_chain(error: &anyhow::Error) -> String { #[cfg(test)] mod tests { use super::{ - CleanupField, Snapshot, TUI_SEARCH_LIMIT, TuiApp, View, WorkflowModal, - failed_diagnostic_reports, format_epoch_day_utc, handle_key, load_snapshot, render, - snooze_until_validation_error, truncate, workflow_table_row_capacity, + AutomationModal, CleanupField, DEFAULT_AUTOMATION_RUN_LIMIT, Snapshot, TUI_SEARCH_LIMIT, + TerminalAction, TuiApp, View, WorkflowModal, automation_match_summary, + ensure_automation_rules_file, failed_diagnostic_reports, format_epoch_day_utc, handle_key, + load_snapshot, parse_editor_command, render, snooze_until_validation_error, truncate, + workflow_table_row_capacity, }; - use crate::config; + use crate::config::{self, WorkspaceConfig}; use crate::mailbox::{self, SearchRequest}; + use crate::store::automation::{ + AutomationActionKind, AutomationActionSnapshot, AutomationMatchReason, + AutomationRunCandidateRecord, AutomationRunDetail, AutomationRunRecord, + AutomationRunStatus, + }; use crate::store::workflows::{ CleanupAction, DraftAttachmentRecord, DraftRevisionDetail, DraftRevisionRecord, ReplyMode, TriageBucket, WorkflowDetail, WorkflowRecord, WorkflowStage, @@ -2326,7 +3390,7 @@ mod tests { app.status, "invalid snooze date: use YYYY-MM-DD or clear the field" ); - let output = render_app(&mut app); + let output = render_app_with_size(&mut app, 140, 30); assert!(output.contains("error: invalid snooze date")); } @@ -2581,7 +3645,7 @@ mod tests { detail: sample_workflow_detail(), })); - let output = render_app(&mut app); + let output = render_app_with_size(&mut app, 140, 30); assert!(output.contains("Current draft")); assert!(output.contains("draft_revision_id: 7")); @@ -2683,6 +3747,217 @@ mod tests { assert!(output.contains("error: automation report failed")); } + #[tokio::test] + async fn automation_run_modal_captures_limit_and_blocks_invalid_limit() { + let temp_dir = TempDir::new().unwrap(); + let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); + let config_report = config::resolve(&paths).unwrap(); + let mut app = TuiApp::new(empty_snapshot(), None); + app.view = View::Automation; + + handle_key(key(KeyCode::Char('n')), &mut app, &paths, &config_report) + .await + .unwrap(); + assert!(matches!( + app.automation_modal, + Some(AutomationModal::RunPreview { ref limit_text }) + if limit_text == &DEFAULT_AUTOMATION_RUN_LIMIT.to_string() + )); + for _ in 0..DEFAULT_AUTOMATION_RUN_LIMIT.to_string().len() { + handle_key( + KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()), + &mut app, + &paths, + &config_report, + ) + .await + .unwrap(); + } + handle_key(key(KeyCode::Char('0')), &mut app, &paths, &config_report) + .await + .unwrap(); + handle_key(key(KeyCode::Enter), &mut app, &paths, &config_report) + .await + .unwrap(); + + assert!(matches!( + app.automation_modal, + Some(AutomationModal::RunPreview { ref limit_text }) if limit_text == "0" + )); + assert_eq!(app.status, "automation run blocked: invalid limit"); + } + + #[tokio::test] + async fn automation_apply_requires_loaded_run_and_apply_confirmation() { + let temp_dir = TempDir::new().unwrap(); + let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); + let config_report = config::resolve(&paths).unwrap(); + let mut app = TuiApp::new(empty_snapshot(), None); + app.view = View::Automation; + + handle_key(key(KeyCode::Char('a')), &mut app, &paths, &config_report) + .await + .unwrap(); + assert!(app.automation_modal.is_none()); + assert_eq!( + app.status, + "automation apply unavailable: load a persisted run first" + ); + + app.automation_detail_report = Some(Ok(crate::automation::AutomationShowReport { + detail: sample_automation_run_detail(), + })); + handle_key(key(KeyCode::Char('a')), &mut app, &paths, &config_report) + .await + .unwrap(); + assert!(matches!( + app.automation_modal, + Some(AutomationModal::ApplyRun { + run_id: 42, + ref confirm_text + }) if confirm_text.is_empty() + )); + + handle_key(key(KeyCode::Enter), &mut app, &paths, &config_report) + .await + .unwrap(); + assert!(matches!( + app.automation_modal, + Some(AutomationModal::ApplyRun { + run_id: 42, + ref confirm_text + }) if confirm_text.is_empty() + )); + assert_eq!( + app.status, + "automation apply blocked: type APPLY before Enter" + ); + } + + #[tokio::test] + async fn automation_apply_blocks_non_preview_runs() { + let temp_dir = TempDir::new().unwrap(); + let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); + let config_report = config::resolve(&paths).unwrap(); + let mut app = TuiApp::new(empty_snapshot(), None); + app.view = View::Automation; + let mut detail = sample_automation_run_detail(); + detail.run.status = AutomationRunStatus::Applied; + app.automation_detail_report = Some(Ok(crate::automation::AutomationShowReport { detail })); + + handle_key(key(KeyCode::Char('a')), &mut app, &paths, &config_report) + .await + .unwrap(); + + assert!(app.automation_modal.is_none()); + assert_eq!( + app.status, + "automation apply unavailable: loaded run is applied" + ); + } + + #[tokio::test] + async fn automation_show_modal_rejects_invalid_run_id() { + let temp_dir = TempDir::new().unwrap(); + let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); + let config_report = config::resolve(&paths).unwrap(); + let mut app = TuiApp::new(empty_snapshot(), None); + app.view = View::Automation; + + handle_key(key(KeyCode::Char('o')), &mut app, &paths, &config_report) + .await + .unwrap(); + handle_key(key(KeyCode::Enter), &mut app, &paths, &config_report) + .await + .unwrap(); + + assert!(matches!( + app.automation_modal, + Some(AutomationModal::ShowRun { ref run_id_text }) if run_id_text.is_empty() + )); + assert_eq!(app.status, "automation show blocked: invalid run id"); + } + + #[tokio::test] + async fn automation_editor_key_queues_terminal_action() { + let temp_dir = TempDir::new().unwrap(); + let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); + let config_report = config::resolve(&paths).unwrap(); + let mut app = TuiApp::new(empty_snapshot(), None); + app.view = View::Automation; + + handle_key(key(KeyCode::Char('e')), &mut app, &paths, &config_report) + .await + .unwrap(); + + assert_eq!( + app.pending_terminal_action, + Some(TerminalAction::EditAutomationRules) + ); + } + + #[test] + fn automation_rules_seed_uses_configured_workspace_paths() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let default_paths = WorkspacePaths::from_repo_root(repo_root.clone()); + let runtime_root = repo_root.join("custom-runtime"); + let configured_paths = WorkspacePaths::from_config( + repo_root, + &WorkspaceConfig { + runtime_root: runtime_root.clone(), + auth_dir: runtime_root.join("auth"), + cache_dir: runtime_root.join("cache"), + state_dir: runtime_root.join("state"), + vault_dir: runtime_root.join("vault"), + exports_dir: runtime_root.join("exports"), + logs_dir: runtime_root.join("logs"), + }, + ); + + let rules_path = ensure_automation_rules_file(&configured_paths).unwrap(); + + assert_eq!(rules_path, runtime_root.join("automation.toml")); + assert!(rules_path.exists()); + assert!(runtime_root.join("auth").exists()); + assert!(!default_paths.runtime_root.exists()); + } + + #[test] + fn editor_command_parser_preserves_args_and_quoted_values() { + let command = parse_editor_command("code --wait 'path with spaces'").unwrap(); + + assert_eq!(command.program, "code"); + assert_eq!(command.args, vec!["--wait", "path with spaces"]); + } + + #[test] + fn editor_command_parser_rejects_unclosed_quotes() { + let error = parse_editor_command("vim 'unterminated").unwrap_err(); + + assert!(error.contains("unterminated quote")); + } + + #[test] + fn automation_renders_loaded_run_candidate_detail() { + let mut app = TuiApp::new(empty_snapshot(), None); + app.view = View::Automation; + app.snapshot.automation = Ok(sample_automation_rollout_report()); + app.automation_detail_report = Some(Ok(crate::automation::AutomationShowReport { + detail: sample_automation_run_detail(), + })); + + let output = render_app(&mut app); + + assert!(output.contains("Saved run 42")); + assert!(output.contains("Candidate detail")); + assert!(output.contains("candidate_id: 7")); + assert!( + automation_match_summary(&sample_automation_run_detail().candidates[0].reason) + .contains("from=sender@example.com") + ); + } + #[tokio::test] async fn snapshot_load_does_not_create_runtime_state() { let temp_dir = TempDir::new().unwrap(); @@ -2741,6 +4016,115 @@ mod tests { snapshot } + fn sample_automation_rollout_report() -> crate::automation::AutomationRolloutReport { + crate::automation::AutomationRolloutReport { + verification: crate::audit::VerificationAuditReport { + account_id: Some(String::from("gmail:me@example.com")), + authenticated: true, + rules_file_path: "automation.toml".into(), + rules_file_exists: true, + bootstrap_query: None, + bootstrap_recent_days: None, + mailbox: None, + store: crate::audit::VerificationStoreSummary { + database_exists: true, + schema_version: Some(16), + message_count: 1, + indexed_message_count: 1, + attachment_count: 0, + vaulted_attachment_count: 0, + attachment_export_count: 0, + workflow_count: 0, + automation_run_count: 1, + }, + label_summary: crate::audit::VerificationLabelSummary { + total_label_count: 1, + empty_user_label_count: 0, + normalized_overlap_count: 0, + numbered_overlap_count: 0, + }, + readiness: crate::audit::VerificationReadiness { + manual_mutation_ready: true, + sender_rule_tuning_ready: true, + list_header_rule_tuning_ready: true, + draft_send_canary_ready: false, + deep_audit_sync_recommended: false, + }, + warnings: Vec::new(), + next_steps: Vec::new(), + }, + rules: None, + selected_rule_ids: vec![String::from("archive-sender")], + selected_rule_count: 1, + candidate_count: 1, + candidates: Vec::new(), + blocked_rule_ids: Vec::new(), + blockers: Vec::new(), + warnings: Vec::new(), + next_steps: Vec::new(), + command_plan: Vec::new(), + } + } + + fn sample_automation_run_detail() -> AutomationRunDetail { + AutomationRunDetail { + run: AutomationRunRecord { + run_id: 42, + account_id: String::from("gmail:me@example.com"), + rule_file_path: String::from(".mailroom/automation.toml"), + rule_file_hash: String::from("hash"), + selected_rule_ids: vec![String::from("archive-sender")], + status: AutomationRunStatus::Previewed, + candidate_count: 1, + created_at_epoch_s: 1_700_000_000, + applied_at_epoch_s: None, + }, + candidates: vec![AutomationRunCandidateRecord { + candidate_id: 7, + run_id: 42, + account_id: String::from("gmail:me@example.com"), + rule_id: String::from("archive-sender"), + thread_id: String::from("thread-1"), + message_id: String::from("message-1"), + internal_date_epoch_ms: 1_700_000_000_000, + subject: String::from("Automation subject"), + from_header: String::from("Sender "), + from_address: Some(String::from("sender@example.com")), + snippet: String::from("snippet"), + label_names: vec![String::from("INBOX")], + attachment_count: 0, + has_list_unsubscribe: false, + list_id_header: None, + list_unsubscribe_header: None, + list_unsubscribe_post_header: None, + precedence_header: None, + auto_submitted_header: None, + action: AutomationActionSnapshot { + kind: AutomationActionKind::Archive, + add_label_ids: Vec::new(), + add_label_names: Vec::new(), + remove_label_ids: vec![String::from("INBOX")], + remove_label_names: vec![String::from("INBOX")], + }, + reason: AutomationMatchReason { + from_address: Some(String::from("sender@example.com")), + subject_terms: Vec::new(), + label_names: vec![String::from("INBOX")], + older_than_days: Some(14), + has_attachments: None, + has_list_unsubscribe: None, + list_id_terms: Vec::new(), + precedence_values: Vec::new(), + }, + apply_status: None, + applied_at_epoch_s: None, + apply_error: None, + created_at_epoch_s: 1_700_000_000, + }], + events: Vec::new(), + } + } + fn sample_workflow(index: usize) -> WorkflowRecord { WorkflowRecord { workflow_id: index as i64,