diff --git a/AGENTS.md b/AGENTS.md index ad0769b..3aec54e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ Local contract for `mailroom`. - Add new dependencies only when they earn their keep for the current milestone. - Preserve structured output for agent and shell workflows. - Prefer read-only audit and verification commands before broad mailbox mutations or high-volume automation apply runs. +- Keep TUI work as a thin shell over existing services and reports; do not duplicate Gmail, workflow, automation, or store ownership in UI code. - If a plugin-assisted Codex workflow exists for an operation, document it alongside native commands rather than pretending the repo already implements it. ## Error handling diff --git a/Cargo.lock b/Cargo.lock index a1ef16c..ac86882 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -190,6 +196,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.60" @@ -283,6 +298,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.37" @@ -318,6 +347,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -361,6 +399,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -371,6 +436,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -389,6 +488,37 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "dialoguer" version = "0.12.0" @@ -443,6 +573,21 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -732,6 +877,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -980,6 +1127,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1032,12 +1185,34 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inlinable_string" version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1060,6 +1235,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1127,6 +1311,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1165,6 +1360,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1177,12 +1381,36 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1206,6 +1434,7 @@ dependencies = [ "base64", "blake3", "clap", + "crossterm", "dialoguer", "directories", "figment", @@ -1216,6 +1445,7 @@ dependencies = [ "mime_guess", "oauth2", "rand 0.8.6", + "ratatui", "reqwest", "rusqlite", "rusqlite_migration", @@ -1271,6 +1501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1281,6 +1512,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-traits" version = "0.2.19" @@ -1300,6 +1537,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "oauth2" version = "5.0.0" @@ -1363,6 +1609,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pear" version = "0.2.9" @@ -1404,6 +1673,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1413,6 +1688,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1589,6 +1870,78 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -1807,6 +2160,12 @@ version = "0.7.0-beta" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "889fe19c32b4d5d42e588152757b960d1a5fca06303a583bcf3f9c920b8b1fc7" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "secrecy" version = "0.10.3" @@ -1929,6 +2288,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1991,12 +2381,39 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2097,6 +2514,27 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tinystr" version = "0.8.3" @@ -2353,6 +2791,23 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 27c875d..7ba30ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,8 @@ tokio = { version = "1.52.0", features = ["io-util", "macros", "net", "rt-multi- toml = "0.9.8" url = "2.5.8" webbrowser = "1.2.0" +ratatui = { version = "0.30.0", default-features = false, features = ["crossterm_0_29"] } +crossterm = "0.29.0" [dev-dependencies] tempfile = "3.23.0" diff --git a/README.md b/README.md index 8727973..b01f88c 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ ## Current posture - Primary stack: Rust + `clap` -- Planned operator surfaces: CLI first, TUI second +- Operator surfaces: CLI first, read-only TUI foundation 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, and review-first automation rules +- 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 read-only terminal operator shell - Hardening surface: read-only label audits, readiness verification, and operator runbooks for safe real-mailbox rollout - Versioned content: code, docs, examples, plans - Ignored runtime content: `.mailroom/` state, caches, exports, secrets, and attachment vaults @@ -45,7 +45,7 @@ Repo-local overrides also live under `.mailroom/`: ## Native commands -The current binary can now resolve config, bootstrap the local store, manage Gmail auth, sync mailbox metadata, search the local cache, catalog inbound attachments, manage thread workflows, sync remote Gmail drafts, and execute reviewed cleanup actions: +The current binary can now resolve config, bootstrap the local store, manage Gmail auth, sync mailbox metadata, search the local cache, catalog inbound attachments, manage thread workflows, sync remote Gmail drafts, execute reviewed cleanup actions, and open a read-only terminal operator shell: ```bash cargo run -- workspace init @@ -67,6 +67,8 @@ cargo run -- sync run --json cargo run -- sync run --full --recent-days 30 --json cargo run -- sync run --profile deep-audit --json cargo run -- search "project alpha" --label INBOX --limit 10 --json +cargo run -- tui +cargo run -- tui --search "project alpha" cargo run -- attachment list --json cargo run -- attachment show m-1:1.2 --json cargo run -- attachment fetch m-1:1.2 --json @@ -166,6 +168,10 @@ Read-only verification and hardening guidance live in [`docs/operations/verification-and-hardening.md`](docs/operations/verification-and-hardening.md), with the durable design captured in [`docs/decisions/0007-verification-audit-hardening.md`](docs/decisions/0007-verification-audit-hardening.md). +The read-only terminal 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). Config precedence is: @@ -208,6 +214,7 @@ Advanced manual overrides still work: - [`docs/decisions/0005-attachment-canonical-model.md`](docs/decisions/0005-attachment-canonical-model.md): attachment catalog, vault, and export ownership - [`docs/decisions/0006-review-first-automation-rules.md`](docs/decisions/0006-review-first-automation-rules.md): review-first automation rules and persisted bulk-action snapshots - [`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/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 @@ -215,12 +222,13 @@ Advanced manual overrides still work: - [`docs/operations/thread-workflow-and-cleanup.md`](docs/operations/thread-workflow-and-cleanup.md): triage, draft/send, snooze, and reviewed cleanup commands - [`docs/operations/automation-rules-and-bulk-actions.md`](docs/operations/automation-rules-and-bulk-actions.md): rule validation, persisted run snapshots, and review-first bulk apply - [`docs/operations/verification-and-hardening.md`](docs/operations/verification-and-hardening.md): deep-sync audit, label canonicalization, canary tests, and first-wave ruleset rollout +- [`docs/operations/tui-operator-shell.md`](docs/operations/tui-operator-shell.md): read-only TUI usage, key bindings, and safety contract - [`docs/operations/plugin-assisted-workflows.md`](docs/operations/plugin-assisted-workflows.md): how Codex Gmail/GitHub workflows fit alongside native commands - [`docs/roadmap/v1-search-triage-draft-queue.md`](docs/roadmap/v1-search-triage-draft-queue.md): first milestone scope ## 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. Expand automation ergonomics only after a few low-surprise micro-batch archive/label runs land cleanly. -3. Expand unsubscribe assistance only after the deeper sync proves out list-header coverage in the local cache. -4. Build a TUI over the existing command core, audit surfaces, and SQLite workflow model. +2. Turn the TUI from read-only inspection into explicit workflow, draft, cleanup, and automation action flows with confirmation screens. +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 dfe0ffe..2e4067b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,13 +17,15 @@ Start here: 5. [`decisions/0005-attachment-canonical-model.md`](decisions/0005-attachment-canonical-model.md) 6. [`decisions/0006-review-first-automation-rules.md`](decisions/0006-review-first-automation-rules.md) 7. [`decisions/0007-verification-audit-hardening.md`](decisions/0007-verification-audit-hardening.md) -8. [`architecture/system-overview.md`](architecture/system-overview.md) -9. [`operations/local-config-and-store.md`](operations/local-config-and-store.md) -10. [`operations/gmail-auth-and-account.md`](operations/gmail-auth-and-account.md) -11. [`operations/mailbox-sync-and-search.md`](operations/mailbox-sync-and-search.md) -12. [`operations/attachment-catalog-and-export.md`](operations/attachment-catalog-and-export.md) -13. [`operations/thread-workflow-and-cleanup.md`](operations/thread-workflow-and-cleanup.md) -14. [`operations/automation-rules-and-bulk-actions.md`](operations/automation-rules-and-bulk-actions.md) -15. [`operations/verification-and-hardening.md`](operations/verification-and-hardening.md) -16. [`operations/plugin-assisted-workflows.md`](operations/plugin-assisted-workflows.md) -17. [`roadmap/v1-search-triage-draft-queue.md`](roadmap/v1-search-triage-draft-queue.md) +8. [`decisions/0008-read-only-tui-foundation.md`](decisions/0008-read-only-tui-foundation.md) +9. [`architecture/system-overview.md`](architecture/system-overview.md) +10. [`operations/local-config-and-store.md`](operations/local-config-and-store.md) +11. [`operations/gmail-auth-and-account.md`](operations/gmail-auth-and-account.md) +12. [`operations/mailbox-sync-and-search.md`](operations/mailbox-sync-and-search.md) +13. [`operations/attachment-catalog-and-export.md`](operations/attachment-catalog-and-export.md) +14. [`operations/thread-workflow-and-cleanup.md`](operations/thread-workflow-and-cleanup.md) +15. [`operations/automation-rules-and-bulk-actions.md`](operations/automation-rules-and-bulk-actions.md) +16. [`operations/verification-and-hardening.md`](operations/verification-and-hardening.md) +17. [`operations/tui-operator-shell.md`](operations/tui-operator-shell.md) +18. [`operations/plugin-assisted-workflows.md`](operations/plugin-assisted-workflows.md) +19. [`roadmap/v1-search-triage-draft-queue.md`](roadmap/v1-search-triage-draft-queue.md) diff --git a/docs/architecture/repository-layout.md b/docs/architecture/repository-layout.md index aeffe3f..88e3460 100644 --- a/docs/architecture/repository-layout.md +++ b/docs/architecture/repository-layout.md @@ -29,6 +29,7 @@ - `src/audit.rs`: read-only label audit and verification/hardening reports - `src/automation/`: rules parsing, snapshot planning, and bulk-apply orchestration - `src/mailbox.rs`: sync/search orchestration over Gmail and SQLite +- `src/tui.rs`: read-only Ratatui operator shell over existing service reports - `src/workflows/`: thread-scoped triage, draft/send, snooze, and cleanup orchestration - `src/doctor.rs`: combined workspace/store/auth health reporting - `src/workspace.rs`: repo-root runtime path layout and initialization @@ -49,4 +50,6 @@ As the codebase grows, prefer a layout along these lines: - `src/workflows/`: triage, drafting, and cleanup flows - `src/tui/`: ratatui application shell -Do not introduce duplicate ownership of workflow or automation rules between CLI, TUI, and adapters. +Do not introduce duplicate ownership of workflow or automation rules between +CLI, TUI, and adapters. TUI actions should call existing service owners rather +than querying or mutating Gmail directly. diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index d4542f2..d2777bb 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -31,11 +31,23 @@ The native Rust core owns: - reviewed cleanup actions for archive, label, and trash - typed automation rule parsing, snapshot persistence, and thread-first bulk apply - read-only label audit, readiness verification, and ruleset-hardening guidance +- read-only Ratatui operator shell over existing diagnostics, search, workflow, and automation reports - structured CLI output ### TUI layer -The TUI should be a thin operator shell over the native core. It must not create a second rules engine or storage model. +The TUI is a thin operator shell over the native core. It must not create a +second rules engine or storage model. + +The current `mailroom tui` shell is read-only and renders: + +- workspace, auth, store, mailbox, and readiness diagnostics +- local SQLite FTS search results +- thread workflow queue rows +- automation rollout readiness and candidate previews + +Mutation-oriented TUI work must reuse the existing CLI/service actions and add +explicit confirmation flows before exposing any Gmail write. ### Plugin-assisted operator path @@ -81,6 +93,7 @@ The current native substrate is intentionally narrow but now usable: - explicit draft send and reviewed cleanup actions that resync the mailbox afterward - typed TOML automation rules and persisted review snapshots for bulk cleanup - read-only audit commands for label taxonomy drift, header coverage, and rollout readiness +- read-only terminal shell over diagnostics, search, workflows, and automation rollout - hardened connection defaults: `foreign_keys=ON`, `trusted_schema=OFF`, `journal_mode=WAL`, `synchronous=NORMAL`, and a nonzero busy timeout This substrate now covers the first complete operator loop for search, thread @@ -108,6 +121,10 @@ Detailed verification and hardening ownership is defined in `docs/decisions/0007-verification-audit-hardening.md` and `docs/operations/verification-and-hardening.md`. +Detailed TUI ownership is defined in +`docs/decisions/0008-read-only-tui-foundation.md` and +`docs/operations/tui-operator-shell.md`. + ## Non-goals for v1 - full mailbox mirroring by default diff --git a/docs/decisions/0008-read-only-tui-foundation.md b/docs/decisions/0008-read-only-tui-foundation.md new file mode 100644 index 0000000..5539f17 --- /dev/null +++ b/docs/decisions/0008-read-only-tui-foundation.md @@ -0,0 +1,76 @@ +# 0008: Read-Only TUI Foundation + +## Status + +Accepted + +## Context + +Mailroom already has CLI-first surfaces for: + +- local SQLite mailbox search +- thread workflow inspection and mutation +- draft/send and reviewed cleanup commands +- review-first automation rules and rollout reports +- read-only label and verification audits + +The next operator need is a faster terminal cockpit for inspecting those +surfaces together. The risk is creating a second workflow engine, rules engine, +or mailbox representation just to make the UI feel richer. + +## Decision + +Mailroom adds `mailroom tui` as a Ratatui-based, read-only operator shell. + +- The TUI is a thin presentation layer over existing read-only services and + reports. +- Startup loads `doctor`, `audit verification`, a read-only workflow list, and + 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. +- `ratatui` is compiled with default features disabled and only the Crossterm + backend enabled to avoid pulling extra widget/backend surface. + +## Why + +- Operators get one screen for readiness, search, workflow queue inspection, and + automation rollout posture without leaving the terminal. +- Read-only scope keeps the first TUI branch safe for a real Gmail account. +- Reusing existing service functions preserves the single SQLite store and the + existing CLI JSON contracts as the source of truth. +- A small TUI foundation gives later mutation-oriented branches a stable place + to add confirmation flows without mixing them into the first shell. + +## Consequences + +Positive: + +- lower-friction daily inspection flow +- no new persisted TUI state +- no new rules engine, query model, or workflow ownership +- clear seam for future production TUI actions + +Negative: + +- the first shell is intentionally not a full mailbox client +- searches still require a prior local sync +- startup report freshness is bounded by the local cache and current rules file + +## Rejected alternatives + +### TUI-first mutation controls + +Rejected for the first TUI slice. Mutation controls need richer confirmation, +diff, canary, and audit affordances than the first shell should carry. + +### Separate TUI store or view models persisted to disk + +Rejected because the existing SQLite store and service reports already own the +needed facts. Persisting TUI-specific state would create drift. + +### Full default Ratatui feature set + +Rejected because Mailroom only needs core widgets and the Crossterm backend for +this shell. Extra default features do not earn their dependency cost yet. diff --git a/docs/operations/tui-operator-shell.md b/docs/operations/tui-operator-shell.md new file mode 100644 index 0000000..b69932a --- /dev/null +++ b/docs/operations/tui-operator-shell.md @@ -0,0 +1,88 @@ +# TUI Operator Shell + +`mailroom tui` opens the native read-only terminal shell. + +It is designed for fast inspection after `workspace init`, auth setup, and a +local sync. It does not replace the CLI JSON contract; it renders the same +underlying read-only reports for human operation and avoids store initialization +or migration side effects while launching. + +## Run + +```bash +cargo run -- tui +``` + +Seed the Search pane with an initial local query: + +```bash +cargo run -- tui --search "project alpha" +``` + +## Views + +- Dashboard: workspace, database, auth, account, mailbox count, and readiness + flags from `doctor` plus `audit verification`. +- Search: local SQLite FTS search through the mailbox read model. +- Workflows: read-only `workflow list` queue overview. +- Automation: read-only `automation rollout` readiness and candidate preview. +- Help: key bindings and safety posture. + +## Keys + +- `q` or `Esc`: quit +- `Tab` / `Shift+Tab`: move between views +- `1` through `5`: jump to a view +- `/`: activate search input +- `Enter`: submit search input +- `r`: refresh dashboard, workflow, and automation reports +- `Ctrl-C`: quit + +## Safety Contract + +The first TUI shell is read-only. + +It does not: + +- send drafts +- create or update Gmail drafts +- archive, label, or trash mail +- apply automation snapshots +- create automation run snapshots +- fetch or export attachments +- edit `.mailroom/automation.toml` + +Use the existing CLI commands for deliberate mutation flows: + +```bash +cargo run -- draft send --json +cargo run -- cleanup archive --execute --json +cargo run -- automation run --limit 10 --json +cargo run -- automation apply --execute --json +``` + +## Troubleshooting + +If the Dashboard has no account, run: + +```bash +cargo run -- auth status --json +cargo run -- account show --json +``` + +If Search is empty or reports no active account, run: + +```bash +cargo run -- sync run --json +cargo run -- search "known term" --json +``` + +If Automation reports missing rules, create or copy a rules file: + +```bash +cp config/automation.example.toml .mailroom/automation.toml +cargo run -- automation rules validate --json +``` + +The TUI intentionally reports these conditions rather than trying to repair or +mutate local state. diff --git a/docs/roadmap/v1-search-triage-draft-queue.md b/docs/roadmap/v1-search-triage-draft-queue.md index b46bde1..d66c970 100644 --- a/docs/roadmap/v1-search-triage-draft-queue.md +++ b/docs/roadmap/v1-search-triage-draft-queue.md @@ -10,7 +10,7 @@ Deliver the first complete operational slice: - draft/reply queue state - reviewed cleanup actions - review-first automation rules and bulk actions -- CLI flows first, TUI surfaces second +- CLI flows first, read-only TUI foundation second ## Included @@ -25,6 +25,7 @@ Deliver the first complete operational slice: - draft queue records, remote Gmail draft sync, and operator notes - reviewed archive, label, and trash actions - review-first automation rules with persisted snapshots +- read-only TUI dashboard/search/workflow/automation inspection shell - plugin-assisted operator documentation ## Current status @@ -72,9 +73,19 @@ The verification and hardening slice is now in place too: - read-only `audit verification` output for deep-sync readiness, header coverage, and first-wave rollout posture - operator runbooks for deep audit syncs, self-canary send tests, and micro-batch archive/label rollout -The next implementation slice should focus on the real personal ruleset rollout -and operator ergonomics on top of the shipped audit surface, not re-open auth, -account, config, store, sync, workflow, attachment, or automation ownership. +The read-only TUI foundation is now in place too: + +- Ratatui shell at `mailroom tui` +- Dashboard, Search, Workflows, Automation, and Help panes +- local search through the existing SQLite-backed search service +- workflow and automation inspection through the existing service reports +- no Gmail write, draft send, cleanup apply, attachment export, or automation + apply controls + +The next implementation slices should focus on production TUI action flows and +the real personal ruleset rollout on top of the shipped audit surface, not +re-open auth, account, config, store, sync, workflow, attachment, or automation +ownership. ## Deferred @@ -91,4 +102,6 @@ account, config, store, sync, workflow, attachment, or automation ownership. An operator can set up the workspace, sync mailbox metadata locally, search it quickly, classify thread work for follow-up, stage and send replies in a durable local system, catalog/export attachments intentionally, and execute reviewed -manual or automation-driven cleanup actions intentionally. +manual or automation-driven cleanup actions intentionally. The same operator can +also open a read-only terminal shell to inspect readiness, search, workflow +queue, and automation rollout posture from the existing local state. diff --git a/src/automation/mod.rs b/src/automation/mod.rs index da628b4..d32396c 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -5,6 +5,7 @@ mod rules; mod service; mod suggestions; +pub(crate) use model::AutomationRolloutReport; pub use model::{ AutomationPruneRequest, AutomationPruneStatus, AutomationRolloutRequest, AutomationRulesSuggestRequest, AutomationRunRequest, DEFAULT_AUTOMATION_ROLLOUT_LIMIT, @@ -14,5 +15,6 @@ pub use model::{ }; pub(crate) use service::AutomationServiceError; pub use service::{ - apply_run, prune_runs, rollout, run_preview, show_run, suggest_rules, validate_rules, + apply_run, prune_runs, rollout, rollout_read_only, run_preview, show_run, suggest_rules, + validate_rules, }; diff --git a/src/automation/service.rs b/src/automation/service.rs index d310291..7dc4b5f 100644 --- a/src/automation/service.rs +++ b/src/automation/service.rs @@ -197,13 +197,24 @@ pub async fn rollout( config_report: &ConfigReport, request: AutomationRolloutRequest, ) -> Result { - if request.limit == 0 { - return Err(AutomationServiceError::InvalidRolloutLimit.into()); - } - + validate_rollout_request(&request)?; ensure_runtime_dirs_task(configured_paths(config_report)?).await?; init_store_task(config_report).await?; + build_rollout_report(config_report, request).await +} + +pub async fn rollout_read_only( + config_report: &ConfigReport, + request: AutomationRolloutRequest, +) -> Result { + validate_rollout_request(&request)?; + build_rollout_report(config_report, request).await +} +async fn build_rollout_report( + config_report: &ConfigReport, + request: AutomationRolloutRequest, +) -> Result { let verification = verification_audit_task(config_report).await?; let mut blockers = Vec::new(); let mut warnings = verification.warnings.clone(); @@ -273,6 +284,13 @@ pub async fn rollout( }) } +fn validate_rollout_request(request: &AutomationRolloutRequest) -> Result<()> { + if request.limit == 0 { + return Err(AutomationServiceError::InvalidRolloutLimit.into()); + } + Ok(()) +} + pub async fn show_run(config_report: &ConfigReport, run_id: i64) -> Result { init_store_task(config_report).await?; let detail = load_run_detail_task(config_report, run_id).await?; diff --git a/src/cli.rs b/src/cli.rs index 3c3172e..0dc4301 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -51,6 +51,8 @@ pub enum Commands { Roadmap, /// Search the local mailbox index Search(SearchArgs), + /// Open the read-only terminal operator shell + Tui(TuiArgs), /// Inspect, fetch, and export cataloged inbound attachments Attachment { #[command(subcommand)] @@ -206,6 +208,13 @@ pub struct SearchArgs { pub json: bool, } +#[derive(Debug, Args)] +pub struct TuiArgs { + /// Seed the search pane with an initial local mailbox query + #[arg(long)] + pub search: Option, +} + #[derive(Debug, Subcommand)] pub enum AttachmentCommand { /// List cataloged attachments from the local mailbox store diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index a6cc61d..8027362 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -9,6 +9,7 @@ mod gmail; mod search; mod store; mod sync; +mod tui; mod workflow; mod workspace; @@ -23,6 +24,7 @@ pub(crate) use gmail::handle_gmail_command; pub(crate) use search::handle_search_command; pub(crate) use store::handle_store_command; pub(crate) use sync::handle_sync_command; +pub(crate) use tui::handle_tui_command; pub(crate) use workflow::{ handle_cleanup_command, handle_draft_command, handle_triage_command, handle_workflow_command, }; diff --git a/src/handlers/tui.rs b/src/handlers/tui.rs new file mode 100644 index 0000000..9f05987 --- /dev/null +++ b/src/handlers/tui.rs @@ -0,0 +1,13 @@ +use crate::cli::TuiArgs; +use crate::{config, tui, workspace}; +use anyhow::Result; +use tokio::task::spawn_blocking; + +pub(crate) async fn handle_tui_command( + paths: &workspace::WorkspacePaths, + args: TuiArgs, +) -> 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 +} diff --git a/src/lib.rs b/src/lib.rs index d8ab805..20179f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ mod handlers; mod mailbox; mod store; mod time; +mod tui; mod workflows; mod workspace; @@ -20,14 +21,14 @@ use cli::{ AccountCommand, AttachmentCommand, AuditCommand, AuthCommand, AutomationCommand, AutomationRulesCommand, CleanupCommand, Cli, Commands, ConfigCommand, DraftAttachmentCommand, DraftCommand, GmailCommand, GmailLabelsCommand, StoreCommand, SyncCommand, SyncPerfCommand, - TriageCommand, WorkflowCommand, + TriageCommand, TuiArgs, WorkflowCommand, }; use handlers::{ handle_account_command, handle_attachment_command, handle_audit_command, handle_auth_command, handle_automation_command, handle_cleanup_command, handle_config_command, handle_doctor_command, handle_draft_command, handle_gmail_command, handle_paths_command, handle_search_command, handle_store_command, handle_sync_command, handle_triage_command, - handle_workflow_command, handle_workspace_command, + handle_tui_command, handle_workflow_command, handle_workspace_command, }; use std::path::{Path, PathBuf}; use std::process::ExitCode; @@ -109,6 +110,7 @@ async fn run_cli(cli: Cli) -> Result<()> { Commands::Gmail { command } => handle_gmail_command(&paths, command).await?, Commands::Roadmap => print_roadmap(), Commands::Search(args) => handle_search_command(&paths, args).await?, + Commands::Tui(args) => handle_tui_command(&paths, args).await?, Commands::Attachment { command } => handle_attachment_command(&paths, command).await?, Commands::Automation { command } => handle_automation_command(&paths, command).await?, Commands::Sync { command } => handle_sync_command(&paths, command).await?, @@ -194,6 +196,10 @@ fn command_metadata(command: &Commands) -> CommandMetadata { json: args.json, operation: "search.run", }, + Commands::Tui(TuiArgs { .. }) => CommandMetadata { + json: false, + operation: "tui.run", + }, Commands::Attachment { command } => match command { AttachmentCommand::List { json, .. } => CommandMetadata { json: *json, @@ -359,10 +365,11 @@ fn command_metadata(command: &Commands) -> CommandMetadata { fn print_roadmap() { println!( - "v1 milestone: search + thread workflow + draft/send + reviewed cleanup + controlled attachment export\n\ + "v1 milestone: search + thread workflow + draft/send + reviewed cleanup + controlled attachment export + review-first automation + read-only TUI\n\ docs: docs/roadmap/v1-search-triage-draft-queue.md\n\ architecture: docs/architecture/system-overview.md\n\ hardening: docs/operations/verification-and-hardening.md\n\ + tui: docs/operations/tui-operator-shell.md\n\ plugin-assisted ops: docs/operations/plugin-assisted-workflows.md" ); } diff --git a/src/mailbox.rs b/src/mailbox.rs index ca8903c..59c5daf 100644 --- a/src/mailbox.rs +++ b/src/mailbox.rs @@ -22,7 +22,7 @@ pub use model::{ SearchReport, SearchRequest, SyncHistoryReport, SyncPerfExplainReport, SyncRunOptions, SyncRunReport, }; -pub use search::search; +pub use search::{search, search_read_only}; pub use sync::{sync_history, sync_perf_explain, sync_run, sync_run_with_options}; pub const DEFAULT_BOOTSTRAP_RECENT_DAYS: u32 = 90; diff --git a/src/mailbox/search.rs b/src/mailbox/search.rs index fe58e05..53c923d 100644 --- a/src/mailbox/search.rs +++ b/src/mailbox/search.rs @@ -6,8 +6,15 @@ use anyhow::{Result, anyhow}; use tokio::task::spawn_blocking; pub async fn search(config_report: &ConfigReport, request: SearchRequest) -> Result { - store::init(config_report)?; + let init_config = config_report.clone(); + spawn_blocking(move || store::init(&init_config)).await??; + search_read_only(config_report, request).await +} +pub async fn search_read_only( + config_report: &ConfigReport, + request: SearchRequest, +) -> Result { let after_epoch_ms = request .after .as_deref() @@ -30,10 +37,10 @@ pub async fn search(config_report: &ConfigReport, request: SearchRequest) -> Res return Err(anyhow!("search limit must be greater than zero")); } let report_terms = terms.clone(); - let account_id = resolve_search_account_id(config_report)?; let label = request.label.clone(); let from_address = request.from_address.clone(); let results = spawn_blocking(move || { + let account_id = resolve_search_account_id(&database_path, busy_timeout_ms)?; store::mailbox::search_messages( &database_path, busy_timeout_ms, @@ -61,18 +68,16 @@ pub async fn search(config_report: &ConfigReport, request: SearchRequest) -> Res }) } -fn resolve_search_account_id(config_report: &ConfigReport) -> Result { - if let Some(active_account) = store::accounts::get_active( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - )? { +fn resolve_search_account_id( + database_path: &std::path::Path, + busy_timeout_ms: u64, +) -> Result { + if let Some(active_account) = store::accounts::get_active(database_path, busy_timeout_ms)? { return Ok(active_account.account_id); } - if let Some(mailbox) = store::mailbox::inspect_mailbox( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - )? && let Some(sync_state) = mailbox.sync_state + if let Some(mailbox) = store::mailbox::inspect_mailbox(database_path, busy_timeout_ms)? + && let Some(sync_state) = mailbox.sync_state { return Ok(sync_state.account_id); } diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..fbe37b1 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,893 @@ +use crate::automation::{self, AutomationRolloutRequest, DEFAULT_AUTOMATION_ROLLOUT_LIMIT}; +use crate::config::ConfigReport; +use crate::doctor::DoctorReport; +use crate::mailbox::{self, SearchReport, SearchRequest}; +use crate::workflows::WorkflowListReport; +use crate::{audit, workspace}; +use anyhow::Result as AnyhowResult; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::Frame; +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, Paragraph, Row, Table, Tabs, Wrap}; +use std::time::Duration; +use tokio::task::spawn_blocking; + +const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(200); +const TUI_SEARCH_LIMIT: usize = 15; +const VIEWS: [View; 5] = [ + View::Dashboard, + View::Search, + View::Workflows, + View::Automation, + View::Help, +]; + +pub async fn run( + paths: &workspace::WorkspacePaths, + config_report: ConfigReport, + initial_search: Option, +) -> AnyhowResult<()> { + let snapshot = load_snapshot(paths, &config_report).await; + let mut app = TuiApp::new(snapshot, initial_search); + if app.has_search_input() { + app.submit_search(&config_report).await; + app.search_editing = false; + } + + let mut terminal = ratatui::try_init()?; + let _guard = TerminalGuard; + + loop { + terminal.draw(|frame| render(frame, &app))?; + + if event::poll(EVENT_POLL_INTERVAL)? { + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + if handle_key(key, &mut app, paths, &config_report).await? { + break; + } + } + } + + Ok(()) +} + +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + ratatui::restore(); + } +} + +#[derive(Debug)] +struct TuiApp { + view: View, + snapshot: Snapshot, + search_input: String, + search_editing: bool, + search_report: Option>, + status: String, +} + +impl TuiApp { + fn new(snapshot: Snapshot, initial_search: Option) -> Self { + let search_input = initial_search.unwrap_or_default(); + let has_search = !search_input.trim().is_empty(); + Self { + view: if has_search { + View::Search + } else { + View::Dashboard + }, + snapshot, + search_input, + search_editing: false, + search_report: None, + status: String::from( + "read-only mode: no Gmail or local mutation actions are available", + ), + } + } + + fn has_search_input(&self) -> bool { + !self.search_input.trim().is_empty() + } + + async fn submit_search(&mut self, config_report: &ConfigReport) { + let terms = self.search_input.trim().to_owned(); + if terms.is_empty() { + self.search_report = Some(Err(String::from("type search terms before pressing enter"))); + self.status = String::from("search skipped: empty query"); + return; + } + + let request = SearchRequest { + terms: terms.clone(), + label: None, + from_address: None, + after: None, + before: None, + limit: TUI_SEARCH_LIMIT, + }; + match mailbox::search_read_only(config_report, request).await { + Ok(report) => { + let count = report.results.len(); + self.search_report = Some(Ok(report)); + self.status = format!("search complete: {count} local hits for \"{terms}\""); + } + Err(error) => { + self.search_report = Some(Err(error_chain(&error))); + self.status = String::from("search failed"); + } + } + } + + async fn refresh(&mut self, paths: &workspace::WorkspacePaths, config_report: &ConfigReport) { + self.snapshot = load_snapshot(paths, config_report).await; + self.status = String::from("read-only reports refreshed"); + } + + fn next_view(&mut self) { + let next = (self.view.index() + 1) % VIEWS.len(); + self.view = VIEWS[next]; + self.search_editing = false; + } + + fn previous_view(&mut self) { + let previous = self.view.index().checked_sub(1).unwrap_or(VIEWS.len() - 1); + self.view = VIEWS[previous]; + self.search_editing = false; + } +} + +#[derive(Debug, Clone)] +struct Snapshot { + doctor: std::result::Result, + verification: std::result::Result, + workflows: std::result::Result, + automation: std::result::Result, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum View { + Dashboard, + Search, + Workflows, + Automation, + Help, +} + +impl View { + const fn index(self) -> usize { + match self { + Self::Dashboard => 0, + Self::Search => 1, + Self::Workflows => 2, + Self::Automation => 3, + Self::Help => 4, + } + } + + const fn label(self) -> &'static str { + match self { + Self::Dashboard => "Dashboard", + Self::Search => "Search", + Self::Workflows => "Workflows", + Self::Automation => "Automation", + Self::Help => "Help", + } + } +} + +async fn load_snapshot( + paths: &workspace::WorkspacePaths, + config_report: &ConfigReport, +) -> Snapshot { + let report_paths = paths.clone(); + let report_config = config_report.clone(); + let report_task = spawn_blocking(move || { + let doctor = DoctorReport::inspect(&report_paths, report_config.clone()) + .map_err(|error| error_chain(&error)); + let verification = audit::verification(&report_config).map_err(|error| error_chain(&error)); + (doctor, verification) + }); + + let (doctor, verification) = match report_task.await { + Ok(reports) => reports, + Err(error) => failed_diagnostic_reports(error), + }; + + let workflows = crate::workflows::list_workflows_read_only(config_report, None, None) + .await + .map_err(|error| error.to_string()); + let automation = automation::rollout_read_only( + config_report, + AutomationRolloutRequest { + rule_ids: Vec::new(), + limit: DEFAULT_AUTOMATION_ROLLOUT_LIMIT, + }, + ) + .await + .map_err(|error| error_chain(&error)); + + Snapshot { + doctor, + verification, + workflows, + automation, + } +} + +fn failed_diagnostic_reports( + error: impl std::fmt::Display, +) -> ( + std::result::Result, + std::result::Result, +) { + ( + Err(format!("diagnostic task failed: {error}")), + Err(format!("verification task failed: {error}")), + ) +} + +async fn handle_key( + key: KeyEvent, + app: &mut TuiApp, + paths: &workspace::WorkspacePaths, + config_report: &ConfigReport, +) -> AnyhowResult { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + return Ok(true); + } + + if app.search_editing { + return handle_search_key(key, app, config_report).await; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => Ok(true), + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + app.next_view(); + Ok(false) + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + app.previous_view(); + Ok(false) + } + KeyCode::Char('1') => { + app.view = View::Dashboard; + Ok(false) + } + KeyCode::Char('2') => { + app.view = View::Search; + Ok(false) + } + KeyCode::Char('3') => { + app.view = View::Workflows; + Ok(false) + } + KeyCode::Char('4') => { + app.view = View::Automation; + Ok(false) + } + KeyCode::Char('5') => { + app.view = View::Help; + Ok(false) + } + KeyCode::Char('/') => { + app.view = View::Search; + app.search_editing = true; + app.status = String::from("search input active; enter runs a local read-only query"); + Ok(false) + } + KeyCode::Char('r') => { + app.refresh(paths, config_report).await; + Ok(false) + } + _ => 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(); + Ok(false) + } + KeyCode::Char(value) => { + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT { + app.search_input.push(value); + } + Ok(false) + } + _ => Ok(false), + } +} + +fn render(frame: &mut Frame<'_>, app: &TuiApp) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(8), + Constraint::Length(2), + ]) + .split(area); + + render_header(frame, chunks[0], app); + match app.view { + View::Dashboard => render_dashboard(frame, chunks[1], app), + View::Search => render_search(frame, chunks[1], app), + View::Workflows => render_workflows(frame, chunks[1], app), + View::Automation => render_automation(frame, chunks[1], app), + View::Help => render_help(frame, chunks[1]), + } + render_footer(frame, chunks[2], app); +} + +fn render_header(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) { + let titles = VIEWS + .iter() + .map(|view| Line::from(Span::styled(view.label(), Style::default().fg(Color::Cyan)))) + .collect::>(); + let tabs = Tabs::new(titles) + .select(app.view.index()) + .block(Block::default().borders(Borders::ALL).title("mailroom")) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + frame.render_widget(tabs, area); +} + +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 | enter submit | r refresh | "), + Span::styled(&app.status, Style::default().fg(Color::Yellow)), + ])])); + frame.render_widget(footer, area); +} + +fn render_dashboard(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + let mut left = Vec::new(); + left.push(Line::from(Span::styled( + "Local readiness", + Style::default().add_modifier(Modifier::BOLD), + ))); + match &app.snapshot.doctor { + Ok(doctor) => { + left.push(metric( + "repo", + doctor.workspace.repo_root.display().to_string(), + )); + left.push(metric( + "runtime", + bool_word(doctor.workspace.runtime_root_exists), + )); + left.push(metric("database", bool_word(doctor.store.database_exists))); + left.push(metric("auth", bool_word(doctor.auth.configured))); + left.push(metric( + "account", + doctor + .auth + .active_account + .as_ref() + .map(|account| account.email_address.as_str()) + .unwrap_or(""), + )); + } + Err(error) => left.push(error_line(error)), + } + if let Ok(report) = &app.snapshot.verification { + left.push(Line::default()); + left.push(metric("messages", report.store.message_count.to_string())); + left.push(metric( + "indexed", + report.store.indexed_message_count.to_string(), + )); + left.push(metric("workflows", report.store.workflow_count.to_string())); + left.push(metric( + "automation runs", + report.store.automation_run_count.to_string(), + )); + } + frame.render_widget( + Paragraph::new(Text::from(left)) + .block(Block::default().borders(Borders::ALL).title("Dashboard")) + .wrap(Wrap { trim: true }), + columns[0], + ); + + let mut right = Vec::new(); + right.push(Line::from(Span::styled( + "Readiness flags", + Style::default().add_modifier(Modifier::BOLD), + ))); + match &app.snapshot.verification { + Ok(report) => { + right.push(metric( + "manual mutation", + bool_word(report.readiness.manual_mutation_ready), + )); + right.push(metric( + "sender tuning", + bool_word(report.readiness.sender_rule_tuning_ready), + )); + right.push(metric( + "list-header tuning", + bool_word(report.readiness.list_header_rule_tuning_ready), + )); + right.push(metric( + "draft canary", + bool_word(report.readiness.draft_send_canary_ready), + )); + right.push(metric( + "deep audit sync", + if report.readiness.deep_audit_sync_recommended { + "recommended" + } else { + "not needed" + }, + )); + render_messages(&mut right, "Warnings", &report.warnings, 4); + render_messages(&mut right, "Next steps", &report.next_steps, 4); + } + Err(error) => right.push(error_line(error)), + } + frame.render_widget( + Paragraph::new(Text::from(right)) + .block(Block::default().borders(Borders::ALL).title("Verification")) + .wrap(Wrap { trim: true }), + columns[1], + ); +} + +fn render_search(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(6)]) + .split(area); + + let title = if app.search_editing { + "Search input (editing)" + } else { + "Search input" + }; + let input_style = if app.search_editing { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + frame.render_widget( + Paragraph::new(app.search_input.as_str()) + .style(input_style) + .block(Block::default().borders(Borders::ALL).title(title)), + chunks[0], + ); + + match &app.search_report { + Some(Ok(report)) => render_search_table(frame, chunks[1], report), + Some(Err(error)) => render_text_panel(frame, chunks[1], "Search", vec![error_line(error)]), + None => render_text_panel( + frame, + chunks[1], + "Search", + vec![ + Line::from("Press /, type local mailbox terms, then press Enter."), + Line::from("This view reads the SQLite FTS index only; it does not call Gmail."), + ], + ), + } +} + +fn render_search_table(frame: &mut Frame<'_>, area: Rect, report: &SearchReport) { + let rows = report.results.iter().map(|result| { + Row::new(vec![ + Cell::from(truncate(&result.subject, 44)), + Cell::from(truncate(&result.from_header, 30)), + Cell::from(result.thread_message_count.to_string()), + Cell::from(truncate(&result.label_names.join(","), 28)), + ]) + }); + let table = Table::new( + rows, + [ + Constraint::Percentage(42), + Constraint::Percentage(28), + Constraint::Length(7), + Constraint::Percentage(30), + ], + ) + .header( + Row::new(vec!["Subject", "From", "Thread", "Labels"]) + .style(Style::default().add_modifier(Modifier::BOLD)), + ) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Search results ({})", report.results.len())), + ); + frame.render_widget(table, area); +} + +fn render_workflows(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) { + match &app.snapshot.workflows { + Ok(report) => { + let rows = report.workflows.iter().take(50).map(|workflow| { + Row::new(vec![ + Cell::from(workflow.workflow_id.to_string()), + Cell::from(workflow.current_stage.to_string()), + Cell::from( + workflow + .triage_bucket + .map(|bucket| bucket.to_string()) + .unwrap_or_else(|| String::from("-")), + ), + Cell::from(truncate(&workflow.latest_message_subject, 44)), + Cell::from(truncate(&workflow.latest_message_from_header, 28)), + ]) + }); + let table = Table::new( + rows, + [ + Constraint::Length(6), + Constraint::Length(14), + Constraint::Length(18), + Constraint::Percentage(42), + Constraint::Percentage(26), + ], + ) + .header( + Row::new(vec!["ID", "Stage", "Bucket", "Subject", "From"]) + .style(Style::default().add_modifier(Modifier::BOLD)), + ) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Workflows ({})", report.workflows.len())), + ); + frame.render_widget(table, area); + } + Err(error) => render_text_panel(frame, area, "Workflows", vec![error_line(error)]), + } +} + +fn render_automation(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(8), Constraint::Min(6)]) + .split(area); + + match &app.snapshot.automation { + Ok(report) => { + let mut summary = vec![ + metric("selected rules", report.selected_rule_count.to_string()), + metric("candidates", report.candidate_count.to_string()), + metric("blocked rules", report.blocked_rule_ids.len().to_string()), + ]; + render_messages(&mut summary, "Blockers", &report.blockers, 3); + render_messages(&mut summary, "Warnings", &report.warnings, 3); + frame.render_widget( + Paragraph::new(Text::from(summary)) + .block( + Block::default() + .borders(Borders::ALL) + .title("Rollout readiness"), + ) + .wrap(Wrap { trim: true }), + chunks[0], + ); + + let rows = report.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("Candidate preview"), + ); + frame.render_widget(table, chunks[1]); + } + Err(error) => render_text_panel(frame, area, "Automation", vec![error_line(error)]), + } +} + +fn render_help(frame: &mut Frame<'_>, area: Rect) { + render_text_panel( + frame, + area, + "Help", + vec![ + Line::from("Read-only operator shell"), + Line::from(""), + 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: inspect thread workflow queue rows."), + Line::from("4 Automation: inspect rollout readiness and preview candidates."), + Line::from("5 Help: key bindings and safety posture."), + Line::from(""), + Line::from( + "No view sends drafts, archives mail, labels mail, trashes mail, applies automation,", + ), + Line::from("exports attachments, or writes automation snapshots."), + ], + ); +} + +fn render_text_panel(frame: &mut Frame<'_>, area: Rect, title: &str, lines: Vec>) { + frame.render_widget( + Paragraph::new(Text::from(lines)) + .block(Block::default().borders(Borders::ALL).title(title)) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_messages(lines: &mut Vec>, title: &str, messages: &[String], limit: usize) { + if messages.is_empty() { + return; + } + + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + title.to_owned(), + Style::default().add_modifier(Modifier::BOLD), + ))); + lines.extend( + messages + .iter() + .take(limit) + .map(|message| Line::from(format!("- {}", truncate(message, 96)))), + ); +} + +fn metric(name: &str, value: impl Into) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{name}: "), Style::default().fg(Color::Cyan)), + Span::raw(value.into()), + ]) +} + +fn error_line(error: &str) -> Line<'static> { + Line::from(Span::styled( + format!("error: {error}"), + Style::default().fg(Color::Red), + )) +} + +fn bool_word(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} + +fn truncate(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_owned(); + } + if max_chars == 0 { + return String::new(); + } + if max_chars <= 3 { + return ".".repeat(max_chars); + } + + let keep = max_chars.saturating_sub(3); + let mut output = value.chars().take(keep).collect::(); + output.push_str("..."); + output +} + +fn error_chain(error: &anyhow::Error) -> String { + error + .chain() + .map(ToString::to_string) + .collect::>() + .join(": ") +} + +#[cfg(test)] +mod tests { + use super::{ + Snapshot, TUI_SEARCH_LIMIT, TuiApp, View, failed_diagnostic_reports, load_snapshot, render, + truncate, + }; + use crate::config; + use crate::mailbox::{self, SearchRequest}; + use crate::workspace::WorkspacePaths; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use tempfile::TempDir; + + #[test] + fn truncate_preserves_short_values() { + assert_eq!(truncate("short", 8), "short"); + } + + #[test] + fn truncate_marks_shortened_values() { + assert_eq!(truncate("abcdefghijkl", 6), "abc..."); + } + + #[test] + fn seeded_search_opens_search_view() { + let app = TuiApp::new(empty_snapshot(), Some(String::from("invoice"))); + assert_eq!(app.view, View::Search); + assert!(!app.search_editing); + } + + #[test] + fn view_navigation_wraps() { + let mut app = TuiApp::new(empty_snapshot(), None); + app.previous_view(); + assert_eq!(app.view, View::Help); + app.next_view(); + assert_eq!(app.view, View::Dashboard); + } + + #[test] + fn failed_diagnostic_reports_preserve_task_failure_context() { + let (doctor, verification) = failed_diagnostic_reports("worker panicked"); + + assert_eq!( + doctor.unwrap_err(), + "diagnostic task failed: worker panicked" + ); + assert_eq!( + verification.unwrap_err(), + "verification task failed: worker panicked" + ); + } + + #[test] + fn dashboard_renders_snapshot_errors() { + let mut app = TuiApp::new(empty_snapshot(), None); + app.snapshot.doctor = Err(String::from("doctor failed")); + app.snapshot.verification = Err(String::from("verification failed")); + + let output = render_app(&app); + + assert!(output.contains("error: doctor failed")); + assert!(output.contains("error: verification failed")); + } + + #[test] + fn search_renders_search_errors() { + let mut app = TuiApp::new(empty_snapshot(), Some(String::from("invoice"))); + app.search_report = Some(Err(String::from("search failed"))); + + let output = render_app(&app); + + assert!(output.contains("error: search failed")); + } + + #[test] + fn workflows_render_report_errors() { + let mut app = TuiApp::new(empty_snapshot(), None); + app.view = View::Workflows; + app.snapshot.workflows = Err(String::from("workflow report failed")); + + let output = render_app(&app); + + assert!(output.contains("error: workflow report failed")); + } + + #[test] + fn automation_renders_report_errors() { + let mut app = TuiApp::new(empty_snapshot(), None); + app.view = View::Automation; + app.snapshot.automation = Err(String::from("automation report failed")); + + let output = render_app(&app); + + assert!(output.contains("error: automation report failed")); + } + + #[tokio::test] + async fn snapshot_load_does_not_create_runtime_state() { + 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(); + + assert!(!paths.runtime_root.exists()); + let snapshot = load_snapshot(&paths, &config_report).await; + + assert!(snapshot.doctor.is_ok()); + assert!(snapshot.verification.is_ok()); + assert!(snapshot.workflows.is_err()); + assert!(!paths.runtime_root.exists()); + } + + #[tokio::test] + async fn search_read_only_does_not_create_runtime_state() { + 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 error = mailbox::search_read_only( + &config_report, + SearchRequest { + terms: String::from("invoice"), + label: None, + from_address: None, + after: None, + before: None, + limit: TUI_SEARCH_LIMIT, + }, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("no active Gmail account")); + assert!(!paths.runtime_root.exists()); + } + + fn empty_snapshot() -> Snapshot { + Snapshot { + doctor: Err(String::from("not loaded")), + verification: Err(String::from("not loaded")), + workflows: Err(String::from("not loaded")), + automation: Err(String::from("not loaded")), + } + } + + fn render_app(app: &TuiApp) -> String { + let backend = TestBackend::new(96, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| render(frame, app)).unwrap(); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect() + } +} diff --git a/src/workflows/mod.rs b/src/workflows/mod.rs index 63f7edd..2e4d459 100644 --- a/src/workflows/mod.rs +++ b/src/workflows/mod.rs @@ -8,6 +8,6 @@ pub use model::{ pub(crate) use service::{WorkflowServiceError, cleanup_tracked_thread_for_automation}; pub use service::{ cleanup_archive, cleanup_label, cleanup_trash, draft_attach_add, draft_attach_remove, - draft_body_set, draft_send, draft_start, list_workflows, promote_workflow, set_triage, - show_workflow, snooze_workflow, + draft_body_set, draft_send, draft_start, list_workflows, list_workflows_read_only, + promote_workflow, set_triage, show_workflow, snooze_workflow, }; diff --git a/src/workflows/service/mod.rs b/src/workflows/service/mod.rs index 86f306a..cd80200 100644 --- a/src/workflows/service/mod.rs +++ b/src/workflows/service/mod.rs @@ -13,7 +13,10 @@ pub use draft_local::{ draft_attach_add, draft_attach_remove, draft_body_set, draft_send, draft_start, }; pub(crate) use error::WorkflowServiceError; -pub use queries::{list_workflows, promote_workflow, set_triage, show_workflow, snooze_workflow}; +pub use queries::{ + list_workflows, list_workflows_read_only, promote_workflow, set_triage, show_workflow, + snooze_workflow, +}; use error::WorkflowResult; diff --git a/src/workflows/service/queries.rs b/src/workflows/service/queries.rs index 478c823..6047b19 100644 --- a/src/workflows/service/queries.rs +++ b/src/workflows/service/queries.rs @@ -18,6 +18,14 @@ pub async fn list_workflows( triage_bucket: Option, ) -> WorkflowResult { store::init(config_report).map_err(|source| WorkflowServiceError::StoreInit { source })?; + list_workflows_read_only(config_report, stage, triage_bucket).await +} + +pub async fn list_workflows_read_only( + config_report: &ConfigReport, + stage: Option, + triage_bucket: Option, +) -> WorkflowResult { let account_id = resolve_workflow_account_id(config_report, None).await?; let database_path = config_report.config.store.database_path.clone(); let busy_timeout_ms = config_report.config.store.busy_timeout_ms;