From fd70ef9511bb8c4360b2c99dfe5d755f209b8bef Mon Sep 17 00:00:00 2001 From: Jeremiah Russell Date: Tue, 21 Oct 2025 11:15:11 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(cli):=20scaffold=20InitCli=20s?= =?UTF-8?q?ubcommand=20and=20clap=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 299 +++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 5 + src/cli/init_cli.rs | 197 +++++++++++++++++++++++++++++ src/cli/main.rs | 13 ++ 4 files changed, 511 insertions(+), 3 deletions(-) create mode 100644 src/cli/init_cli.rs diff --git a/Cargo.lock b/Cargo.lock index b4e84c9..3bfd705 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,6 +101,37 @@ dependencies = [ "serde_json", ] +[[package]] +name = "assert_cmd" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd389a4b2970a01282ee455294913c0a43724daedcd1a24c3eb0ec1c1320b66" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "assert_fs" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652f6cb1f516886fcfee5e7a5c078b9ade62cfcb889524efe5a64d682dd27a9" +dependencies = [ + "anstyle", + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + [[package]] name = "async-lock" version = "3.4.1" @@ -203,6 +234,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -327,6 +369,19 @@ dependencies = [ "winnow", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -361,6 +416,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -381,23 +455,28 @@ dependencies = [ name = "cull-gmail" version = "0.0.11" dependencies = [ + "assert_cmd", + "assert_fs", "base64", "chrono", "clap", "clap-verbosity-flag", "config", + "dialoguer", "env_logger", "flate2", "futures", "google-gmail1", "httpmock", + "indicatif", "lazy-regex", "log", + "predicates", "serde", "serde_json", "temp-env", "tempfile", - "thiserror", + "thiserror 2.0.17", "tokio", "tokio-test", "toml", @@ -448,6 +527,25 @@ dependencies = [ "serde_core", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -469,6 +567,12 @@ dependencies = [ "syn", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -481,6 +585,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "env_filter" version = "0.1.3" @@ -563,6 +673,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -712,6 +831,30 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "globset" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab69130804d941f8075cfd713bf8848a2c3b3f201a9457a11e6f87e1ab62305" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + [[package]] name = "google-apis-common" version = "7.0.0" @@ -896,7 +1039,7 @@ dependencies = [ "similar", "stringmetrics", "tabwriter", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "url", @@ -1100,6 +1243,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81776e6f9464432afcc28d03e52eb101c93b6f0566f52aef2427663e700f0403" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1123,6 +1282,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "io-uring" version = "0.7.10" @@ -1278,6 +1450,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num-conv" version = "0.1.0" @@ -1302,6 +1480,12 @@ dependencies = [ "libc", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "object" version = "0.37.3" @@ -1421,6 +1605,36 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -1603,6 +1817,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -1776,6 +1999,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.3.0" @@ -1902,13 +2131,39 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2186,6 +2441,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2269,6 +2543,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.1", +] + [[package]] name = "windows-core" version = "0.62.1" diff --git a/Cargo.toml b/Cargo.toml index 86c02b0..3182853 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,8 @@ tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "process" toml = "0.9.7" base64 = "0.22" flate2 = "1.0" +dialoguer = "0.11" +indicatif = "0.17" [dev-dependencies] httpmock = "0.8" @@ -46,6 +48,9 @@ tokio-test = "0.4" temp-env = "0.3" tempfile = "3.12" futures = "0.3" +assert_cmd = "2.0" +assert_fs = "1.1" +predicates = "3.1" [lints.clippy] uninlined-format-args = "warn" diff --git a/src/cli/init_cli.rs b/src/cli/init_cli.rs new file mode 100644 index 0000000..990c15b --- /dev/null +++ b/src/cli/init_cli.rs @@ -0,0 +1,197 @@ +//! # Initialization CLI Module +//! +//! This module provides CLI functionality for initializing the cull-gmail application, +//! including creating configuration directories, setting up OAuth2 credentials, +//! generating default configuration files, and completing the initial authentication flow. +//! +//! ## Overview +//! +//! The initialization system allows users to: +//! +//! - **Create configuration directory**: Set up the cull-gmail configuration directory +//! - **Install credentials**: Copy and validate OAuth2 credential files +//! - **Generate configuration**: Create default cull-gmail.toml and rules.toml files +//! - **Complete OAuth2 flow**: Authenticate with Gmail API and persist tokens +//! - **Interactive setup**: Guide users through setup with prompts and confirmations +//! - **Dry-run mode**: Preview all actions without making changes +//! +//! ## Use Cases +//! +//! ### First-time Setup +//! ```bash +//! # Interactive setup with credential file +//! cull-gmail init --interactive --credential-file ~/Downloads/client_secret.json +//! +//! # Non-interactive setup (credential file copied manually later) +//! cull-gmail init --config-dir ~/.cull-gmail +//! ``` +//! +//! ### Planning and Verification +//! ```bash +//! # See what would be created without making changes +//! cull-gmail init --dry-run +//! +//! # Preview with specific options +//! cull-gmail init --config-dir /custom/path --credential-file credentials.json --dry-run +//! ``` +//! +//! ### Force Overwrite +//! ```bash +//! # Recreate configuration, backing up existing files +//! cull-gmail init --force +//! ``` +//! +//! ## Security Considerations +//! +//! - **Credential Protection**: OAuth2 credential files are copied with 0600 permissions +//! - **Token Directory**: Token cache directory is created with 0700 permissions +//! - **Backup Safety**: Existing files are backed up with timestamps before overwriting +//! - **Interactive Confirmation**: Prompts for confirmation before overwriting existing files + +use clap::Parser; +use std::path::PathBuf; + +/// Initialize cull-gmail configuration, credentials, and OAuth2 tokens. +/// +/// This command sets up the complete cull-gmail environment by creating the configuration +/// directory, installing OAuth2 credentials, generating default configuration files, +/// and completing the initial Gmail API authentication to persist tokens. +/// +/// ## Setup Process +/// +/// 1. **Configuration Directory**: Create or verify the configuration directory +/// 2. **Credential Installation**: Copy and validate OAuth2 credential file (if provided) +/// 3. **Configuration Generation**: Create cull-gmail.toml with safe defaults +/// 4. **Rules Template**: Generate rules.toml with example retention rules +/// 5. **Token Directory**: Ensure OAuth2 token cache directory exists +/// 6. **Authentication**: Complete OAuth2 flow to generate and persist tokens +/// +/// ## Interactive vs Non-Interactive +/// +/// - **Non-interactive** (default): Proceeds with provided options, fails if conflicts exist +/// - **Interactive** (`--interactive`): Prompts for missing information and confirmation for conflicts +/// - **Dry-run** (`--dry-run`): Shows planned actions without making any changes +/// +/// ## Examples +/// +/// ```bash +/// # Basic initialization +/// cull-gmail init +/// +/// # Interactive setup with credential file +/// cull-gmail init --interactive --credential-file client_secret.json +/// +/// # Custom configuration directory +/// cull-gmail init --config-dir /path/to/config +/// +/// # Preview actions without changes +/// cull-gmail init --dry-run +/// +/// # Force overwrite existing files +/// cull-gmail init --force +/// ``` +#[derive(Parser, Debug)] +pub struct InitCli { + /// Configuration directory path. + /// + /// Supports path prefixes: + /// - `h:path` - Relative to home directory (default: `h:.cull-gmail`) + /// - `c:path` - Relative to current directory + /// - `r:path` - Relative to filesystem root + /// - `path` - Use path as-is + #[arg( + long = "config-dir", + value_name = "DIR", + default_value = "h:.cull-gmail", + help = "Configuration directory path" + )] + pub config_dir: String, + + /// OAuth2 credential file path. + /// + /// This should be the JSON file downloaded from Google Cloud Console + /// containing your OAuth2 client credentials for Desktop application type. + /// The file will be copied to the configuration directory as `credential.json`. + #[arg( + long = "credential-file", + value_name = "PATH", + help = "Path to OAuth2 credential JSON file" + )] + pub credential_file: Option, + + /// Overwrite existing files without prompting. + /// + /// When enabled, existing configuration files will be backed up with + /// timestamps and then overwritten with new defaults. Use with caution + /// as this will replace your current configuration. + #[arg( + long = "force", + help = "Overwrite existing files (creates timestamped backups)" + )] + pub force: bool, + + /// Show planned actions without making changes. + /// + /// Enables preview mode where all planned operations are displayed + /// but no files are created, modified, or removed. OAuth2 authentication + /// flow is also skipped in dry-run mode. + #[arg( + long = "dry-run", + help = "Preview actions without making changes" + )] + pub dry_run: bool, + + /// Enable interactive prompts and confirmations. + /// + /// When enabled, the command will prompt for missing information + /// (such as credential file path) and ask for confirmation before + /// overwriting existing files. Recommended for first-time users. + #[arg( + long = "interactive", + short = 'i', + help = "Prompt for missing information and confirmations" + )] + pub interactive: bool, +} + +impl InitCli { + /// Execute the initialization command. + /// + /// This method orchestrates the complete initialization workflow including + /// configuration directory creation, credential installation, file generation, + /// and OAuth2 authentication based on the provided command-line options. + /// + /// # Returns + /// + /// Returns `Result<()>` indicating success or failure of the initialization process. + /// + /// # Process Flow + /// + /// 1. **Plan Operations**: Analyze current state and generate operation plan + /// 2. **Validate Inputs**: Check credential file validity and resolve paths + /// 3. **Interactive Prompts**: Request missing information if in interactive mode + /// 4. **Execute or Preview**: Apply operations or show dry-run preview + /// 5. **OAuth2 Flow**: Complete authentication and token generation + /// 6. **Success Reporting**: Display results and next steps + /// + /// # Errors + /// + /// This method can return errors for: + /// - Invalid or missing credential files + /// - File system permission issues + /// - Configuration conflicts without force or interactive resolution + /// - OAuth2 authentication failures + /// - Network connectivity issues during authentication + pub async fn run(&self) -> cull_gmail::Result<()> { + log::info!("Starting cull-gmail initialization"); + + if self.dry_run { + println!("DRY RUN: No changes will be made"); + } + + // TODO: Implement initialization logic + println!("Init command called with options: {self:?}"); + + Ok(()) + } +} \ No newline at end of file diff --git a/src/cli/main.rs b/src/cli/main.rs index f666171..66b07f4 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -111,6 +111,7 @@ use clap::{Parser, Subcommand}; +mod init_cli; mod labels_cli; mod messages_cli; mod rules_cli; @@ -120,6 +121,7 @@ use config::Config; use cull_gmail::{ClientConfig, EolAction, GmailClient, Result, RuleProcessor, Rules}; use std::{env, error::Error as stdError}; +use init_cli::InitCli; use labels_cli::LabelsCli; use messages_cli::MessagesCli; use rules_cli::RulesCli; @@ -175,6 +177,13 @@ struct Cli { /// then query specific messages, and finally configure automated rules. #[derive(Subcommand, Debug)] enum SubCmds { + /// Initialize cull-gmail configuration, credentials, and OAuth2 tokens. + /// + /// Sets up the complete cull-gmail environment including configuration directory, + /// OAuth2 credentials, default configuration files, and initial authentication flow. + #[clap(name = "init", display_order = 1)] + Init(InitCli), + /// Query, filter, and perform batch operations on Gmail messages. /// /// Supports advanced Gmail query syntax, label filtering, and batch actions @@ -295,6 +304,10 @@ async fn run(args: Cli) -> Result<()> { }; match sub_command { + SubCmds::Init(init_cli) => { + // Init commands don't need a Gmail client since they set up the config + init_cli.run().await + } SubCmds::Message(messages_cli) => messages_cli.run(&mut client).await, SubCmds::Labels(labels_cli) => labels_cli.run(client).await, SubCmds::Rules(rules_cli) => rules_cli.run(&mut client).await,