//! # 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 //! //! # Skip rules.toml creation for ephemeral environments //! cull-gmail init --skip-rules //! ``` //! //! ### 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 //! ``` //! //! ### Ephemeral Environments //! ```bash //! # Skip rules.toml creation when it's provided externally //! cull-gmail init --skip-rules --config-dir /app/config //! //! # Skip rules with custom rules directory //! cull-gmail init --skip-rules --rules-dir /mnt/rules //! ``` //! //! ## 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 chrono::Local; use clap::Parser; use dialoguer::{Confirm, Input}; use google_gmail1::yup_oauth2::ConsoleApplicationSecret; use indicatif::{ProgressBar, ProgressStyle}; use std::path::{Path, PathBuf}; use std::{env, fs}; use cull_gmail::{ClientConfig, Error, GmailClient, Result}; use lazy_regex::{Lazy, Regex, lazy_regex}; /// Parse configuration root path with h:, c:, r: prefixes. /// /// Supports: /// - `h:path` - Relative to home directory /// - `c:path` - Relative to current directory /// - `r:path` - Relative to filesystem root /// - `path` - Use path as-is pub fn parse_config_root(path_str: &str) -> PathBuf { static ROOT_CONFIG: Lazy = lazy_regex!(r"^(?P[hrc]):(?P.+)$"); if let Some(captures) = ROOT_CONFIG.captures(path_str) { let path_part = captures.name("path").map_or("", |m| m.as_str()); let class = captures.name("class").map_or("", |m| m.as_str()); match class { "h" => env::home_dir().unwrap_or_default().join(path_part), "c" => env::current_dir().unwrap_or_default().join(path_part), "r" => PathBuf::from("/").join(path_part), _ => PathBuf::from(path_str), } } else { PathBuf::from(path_str) } } /// 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, /// Rules file directory path. /// /// Optionally specify a separate directory for the rules.toml file. /// If not provided, rules.toml will be created in the configuration directory. /// Supports the same path prefixes as --config-dir (h:, c:, r:). /// /// This is useful for: /// - Version controlling rules separately from credentials /// - Sharing rules across multiple configurations /// - Organizing files by security sensitivity #[arg( long = "rules-dir", value_name = "DIR", help = "Optional separate directory for rules.toml file" )] pub rules_dir: 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, /// Skip rules.toml file creation. /// /// When enabled, the rules.toml file will not be created during initialization. /// This is useful for ephemeral compute environments where rules.toml is provided /// externally (e.g., mounted from a volume or supplied via configuration management). /// /// The cull-gmail.toml configuration file will still reference the rules.toml path /// with a comment indicating that it should be provided separately. /// /// If --rules-dir is also specified, the rules directory will be created but the /// rules.toml file within it will not be generated. #[arg( long = "skip-rules", help = "Do not create rules.toml; expect it to be provided externally" )] pub skip_rules: bool, } /// Operations that can be performed during initialization. /// /// Each operation represents a discrete action that needs to be taken /// to set up the cull-gmail environment. Operations are planned first /// and then executed in the correct order with appropriate error handling. #[derive(Debug, Clone)] enum Operation { /// Create a directory with specified permissions. CreateDir { path: PathBuf, #[cfg(unix)] mode: Option, }, /// Copy a file from source to destination with optional chmod and backup. CopyFile { from: PathBuf, to: PathBuf, #[cfg(unix)] mode: Option, backup_if_exists: bool, }, /// Write content to a file with optional permissions and backup. WriteFile { path: PathBuf, contents: String, #[cfg(unix)] mode: Option, backup_if_exists: bool, }, /// Ensure token directory exists with secure permissions. EnsureTokenDir { path: PathBuf, #[cfg(unix)] mode: Option, }, /// Run OAuth2 authentication flow. RunOAuth2 { config_root: String, credential_file: Option, }, } impl std::fmt::Display for Operation { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Operation::CreateDir { path, .. } => { write!(f, "Create directory: {}", path.display()) } Operation::CopyFile { from, to, backup_if_exists, .. } => { if *backup_if_exists && to.exists() { write!( f, "Copy file: {} → {} (with backup)", from.display(), to.display() ) } else { write!(f, "Copy file: {} → {}", from.display(), to.display()) } } Operation::WriteFile { path, backup_if_exists, .. } => { if *backup_if_exists && path.exists() { write!(f, "Write file: {} (with backup)", path.display()) } else { write!(f, "Write file: {}", path.display()) } } Operation::EnsureTokenDir { path, .. } => { write!(f, "Ensure token directory: {}", path.display()) } Operation::RunOAuth2 { .. } => { write!(f, "Run OAuth2 authentication flow") } } } } /// Configuration defaults for initialization. struct InitDefaults; impl InitDefaults { const CONFIG_FILE_CONTENT: &'static str = r#"# cull-gmail configuration # This file configures the cull-gmail application. # OAuth2 credential file (relative to config_root) credential_file = "credential.json" # Configuration root directory config_root = "h:.cull-gmail" # Rules configuration file rules = "rules.toml" # Default execution mode (false = dry-run, true = execute) # Set to false for safety - you can override with --execute flag execute = false # Environment variable name for token cache (for ephemeral environments) token_cache_env = "CULL_GMAIL_TOKEN_CACHE" "#; /// Generate config file content with custom rules path. fn config_content_with_rules_path(rules_path: &str) -> String { format!( r#"# cull-gmail configuration # This file configures the cull-gmail application. # OAuth2 credential file (relative to config_root) credential_file = "credential.json" # Configuration root directory config_root = "h:.cull-gmail" # Rules configuration file (supports h:, c:, r: prefixes) rules = "{rules_path}" # Default execution mode (false = dry-run, true = execute) # Set to false for safety - you can override with --execute flag execute = false # Environment variable name for token cache (for ephemeral environments) token_cache_env = "CULL_GMAIL_TOKEN_CACHE" "# ) } /// Generate config file content with skip-rules comment. fn config_content_with_skip_rules(rules_path: &str) -> String { format!( r#"# cull-gmail configuration # This file configures the cull-gmail application. # OAuth2 credential file (relative to config_root) credential_file = "credential.json" # Configuration root directory config_root = "h:.cull-gmail" # Rules configuration file (supports h:, c:, r: prefixes) # NOTE: rules.toml creation was skipped via --skip-rules flag # The rules file is expected to be provided externally (e.g., in ephemeral environments) rules = "{rules_path}" # Default execution mode (false = dry-run, true = execute) # Set to false for safety - you can override with --execute flag execute = false # Environment variable name for token cache (for ephemeral environments) token_cache_env = "CULL_GMAIL_TOKEN_CACHE" "# ) } const RULES_FILE_CONTENT: &'static str = r#"# Example rules for cull-gmail # Each rule targets a Gmail label and specifies an action. # # Actions: # - "Trash" is recoverable (messages go to Trash folder ~30 days) # - "Delete" is irreversible (messages are permanently deleted) # # Time formats: # - "older_than:30d" (30 days) # - "older_than:6m" (6 months) # - "older_than:2y" (2 years) # # Example rule for promotional emails: # [[rules]] # id = 1 # label = "Promotions" # query = "category:promotions older_than:30d" # action = "Trash" # # Example rule for old newsletters: # [[rules]] # id = 2 # label = "Updates" # query = "category:updates older_than:90d" # action = "Trash" # # Uncomment and modify the examples above to create your own rules. # Run 'cull-gmail rules run --dry-run' to test rules before execution. "#; fn credential_filename() -> &'static str { "credential.json" } fn config_filename() -> &'static str { "cull-gmail.toml" } fn rules_filename() -> &'static str { "rules.toml" } fn token_dir_name() -> &'static str { "gmail1" } } 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) -> Result<()> { log::info!("Starting cull-gmail initialization"); if self.dry_run { println!("🔍 DRY RUN: No changes will be made\n"); } // Resolve configuration directory path let config_path = parse_config_root(&self.config_dir); log::info!("Configuration directory: {}", config_path.display()); // Handle interactive credential file prompt if needed let credential_file = self.get_credential_file().await?; // Plan all operations let operations = self.plan_operations(&config_path, credential_file.as_ref())?; // Show plan in dry-run mode if self.dry_run { self.show_plan(&operations); return Ok(()); } // Execute operations self.execute_operations(&operations).await?; // Show success message and next steps self.show_completion(&config_path); Ok(()) } /// Get credential file path, prompting if interactive and not provided. async fn get_credential_file(&self) -> Result> { if let Some(ref cred_file) = self.credential_file { // Validate the provided credential file self.validate_credential_file(cred_file)?; return Ok(Some(cred_file.clone())); } if self.interactive { println!("📋 OAuth2 credential file setup"); println!("You need a credential JSON file from Google Cloud Console."); println!("Visit: https://console.cloud.google.com/apis/credentials\n"); let should_provide = Confirm::new() .with_prompt("Do you have a credential file to set up now?") .default(true) .interact() .map_err(|e| Error::FileIo(format!("Interactive prompt failed: {e}")))?; if should_provide { let cred_path: String = Input::new() .with_prompt("Path to credential JSON file") .interact_text() .map_err(|e| Error::FileIo(format!("Interactive input failed: {e}")))?; let cred_file = PathBuf::from(cred_path); self.validate_credential_file(&cred_file)?; return Ok(Some(cred_file)); } else { println!("⏭️ Skipping credential setup - you can add it later\n"); } } Ok(None) } /// Validate that a credential file exists and can be parsed. fn validate_credential_file(&self, path: &Path) -> Result<()> { if !path.exists() { return Err(Error::FileIo(format!( "Credential file not found: {}", path.display() ))); } let content = fs::read_to_string(path) .map_err(|e| Error::FileIo(format!("Cannot read credential file: {e}")))?; // Try to parse as ConsoleApplicationSecret to validate format serde_json::from_str::(&content).map_err(|e| { Error::SerializationError(format!("Invalid credential file format: {e}")) })?; log::info!("Credential file validated: {}", path.display()); Ok(()) } /// Plan all operations needed for initialization. fn plan_operations( &self, config_path: &Path, credential_file: Option<&PathBuf>, ) -> Result> { let mut operations = Vec::new(); // 1. Create config directory if it doesn't exist self.plan_config_directory(&mut operations, config_path); // 2. Copy credential file if provided if let Some(cred_file) = credential_file { self.plan_credential_file_operation(&mut operations, config_path, cred_file)?; } // 3. Determine rules directory let rules_dir = self.get_rules_directory(config_path); // 4. Write config file (with correct rules path) self.plan_config_file_operation(&mut operations, config_path, &rules_dir)?; // 5. Write rules file (possibly in separate directory) self.plan_rules_file_operation(&mut operations, &rules_dir)?; // 6. Ensure token directory exists self.plan_token_directory(&mut operations, config_path); // 7. Run OAuth2 if we have credentials if credential_file.is_some() { self.plan_oauth_operation(&mut operations); } Ok(operations) } /// Get the directory where rules.toml should be placed. /// /// Returns the rules directory path, which is either: /// - The custom directory specified with --rules-dir /// - The config directory (default) fn get_rules_directory(&self, config_path: &Path) -> PathBuf { if let Some(ref rules_dir_str) = self.rules_dir { parse_config_root(rules_dir_str) } else { config_path.to_path_buf() } } /// Plan config directory creation. fn plan_config_directory(&self, operations: &mut Vec, config_path: &Path) { if !config_path.exists() { operations.push(Operation::CreateDir { path: config_path.to_path_buf(), #[cfg(unix)] mode: Some(0o755), }); } } /// Plan credential file copy operation. fn plan_credential_file_operation( &self, operations: &mut Vec, config_path: &Path, cred_file: &Path, ) -> Result<()> { let dest_path = config_path.join(InitDefaults::credential_filename()); self.check_file_conflicts(&dest_path, "Credential file")?; operations.push(Operation::CopyFile { from: cred_file.to_path_buf(), to: dest_path.clone(), #[cfg(unix)] mode: Some(0o600), backup_if_exists: self.should_backup(&dest_path), }); Ok(()) } /// Plan config file write operation. fn plan_config_file_operation( &self, operations: &mut Vec, config_path: &Path, rules_dir: &Path, ) -> Result<()> { let config_file_path = config_path.join(InitDefaults::config_filename()); self.check_file_conflicts(&config_file_path, "Configuration file")?; // Generate rules path for config file let rules_path = rules_dir.join(InitDefaults::rules_filename()); let rules_path_str = rules_path.to_string_lossy().to_string(); let config_contents = if self.skip_rules { // Skip rules mode - add comment about external provision if rules_dir == config_path { InitDefaults::config_content_with_skip_rules(InitDefaults::rules_filename()) } else { InitDefaults::config_content_with_skip_rules(&rules_path_str) } } else if rules_dir == config_path { // Rules in same directory - use relative path InitDefaults::CONFIG_FILE_CONTENT.to_string() } else { // Rules in different directory - use full path InitDefaults::config_content_with_rules_path(&rules_path_str) }; operations.push(Operation::WriteFile { path: config_file_path.clone(), contents: config_contents, #[cfg(unix)] mode: Some(0o644), backup_if_exists: self.should_backup(&config_file_path), }); Ok(()) } /// Plan rules file write operation. fn plan_rules_file_operation( &self, operations: &mut Vec, rules_dir: &Path, ) -> Result<()> { // Create rules directory if it doesn't exist and hasn't been planned already let already_planned = operations .iter() .any(|op| matches!(op, Operation::CreateDir { path, .. } if path == rules_dir)); if !rules_dir.exists() && !already_planned { operations.push(Operation::CreateDir { path: rules_dir.to_path_buf(), #[cfg(unix)] mode: Some(0o755), }); } // Skip rules file creation if --skip-rules is set if self.skip_rules { log::info!("Skipping rules.toml creation due to --skip-rules flag"); return Ok(()); } let rules_file_path = rules_dir.join(InitDefaults::rules_filename()); self.check_file_conflicts(&rules_file_path, "Rules file")?; operations.push(Operation::WriteFile { path: rules_file_path.clone(), contents: InitDefaults::RULES_FILE_CONTENT.to_string(), #[cfg(unix)] mode: Some(0o644), backup_if_exists: self.should_backup(&rules_file_path), }); Ok(()) } /// Plan token directory creation. fn plan_token_directory(&self, operations: &mut Vec, config_path: &Path) { let token_dir = config_path.join(InitDefaults::token_dir_name()); operations.push(Operation::EnsureTokenDir { path: token_dir, #[cfg(unix)] mode: Some(0o700), }); } /// Plan OAuth2 operation. fn plan_oauth_operation(&self, operations: &mut Vec) { operations.push(Operation::RunOAuth2 { config_root: self.config_dir.clone(), credential_file: Some(InitDefaults::credential_filename().to_string()), }); } /// Check for file conflicts and return appropriate error if needed. fn check_file_conflicts(&self, file_path: &Path, file_type: &str) -> Result<()> { if file_path.exists() && !self.force && !self.interactive { return Err(Error::FileIo(format!( "{} already exists: {}\nUse --force to overwrite or --interactive for prompts", file_type, file_path.display() ))); } Ok(()) } /// Determine if a file should be backed up. fn should_backup(&self, file_path: &Path) -> bool { file_path.exists() && self.force } /// Show the planned operations in dry-run mode. fn show_plan(&self, operations: &[Operation]) { println!("📋 Planned operations:"); for (i, op) in operations.iter().enumerate() { println!(" {}. {}", i + 1, op); } println!(); // Show skip-rules notice if applicable if self.skip_rules { println!("📝 rules.toml: skipped (per --skip-rules flag)"); println!(" The rules file path is configured in cull-gmail.toml"); println!(" Expected to be provided externally (e.g., in ephemeral environments)"); println!(); } if operations .iter() .any(|op| matches!(op, Operation::RunOAuth2 { .. })) { println!("🔐 OAuth2 authentication would open your browser for Gmail authorization"); } else { println!("⚠️ OAuth2 authentication skipped - no credential file provided"); println!(" Add a credential file later and run 'cull-gmail init' again"); } println!(); println!("To apply these changes, run without --dry-run"); } /// Execute all planned operations. async fn execute_operations(&self, operations: &[Operation]) -> Result<()> { let pb = ProgressBar::new(operations.len() as u64); pb.set_style( ProgressStyle::default_bar() .template( "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}", ) .unwrap() .progress_chars("#>-"), ); for (i, operation) in operations.iter().enumerate() { pb.set_position(i as u64); pb.set_message(format!("{operation}")); self.execute_operation(operation).await?; // Small delay to make progress visible tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } pb.finish_with_message("✅ All operations completed"); println!(); Ok(()) } /// Execute a single operation. async fn execute_operation(&self, operation: &Operation) -> Result<()> { match operation { Operation::CreateDir { path, .. } => { self.execute_create_directory(path, operation).await } Operation::CopyFile { from, to, backup_if_exists, .. } => { self.execute_copy_file(from, to, *backup_if_exists, operation) .await } Operation::WriteFile { path, contents, backup_if_exists, .. } => { self.execute_write_file(path, contents, *backup_if_exists, operation) .await } Operation::EnsureTokenDir { path, .. } => { self.execute_ensure_token_directory(path, operation).await } Operation::RunOAuth2 { config_root, credential_file, } => self.execute_oauth_flow(config_root, credential_file).await, } } /// Execute directory creation operation. async fn execute_create_directory(&self, path: &Path, operation: &Operation) -> Result<()> { log::info!("Creating directory: {}", path.display()); fs::create_dir_all(path) .map_err(|e| Error::FileIo(format!("Failed to create directory: {e}")))?; self.apply_permissions_if_needed(path, operation) } /// Execute file copy operation. async fn execute_copy_file( &self, from: &Path, to: &Path, backup_if_exists: bool, operation: &Operation, ) -> Result<()> { self.handle_existing_file(to, backup_if_exists, "file copy") .await?; log::info!("Copying file: {} → {}", from.display(), to.display()); fs::copy(from, to).map_err(|e| Error::FileIo(format!("Failed to copy file: {e}")))?; self.apply_permissions_if_needed(to, operation) } /// Execute file write operation. async fn execute_write_file( &self, path: &Path, contents: &str, backup_if_exists: bool, operation: &Operation, ) -> Result<()> { self.handle_existing_file(path, backup_if_exists, "file write") .await?; log::info!("Writing file: {}", path.display()); fs::write(path, contents) .map_err(|e| Error::FileIo(format!("Failed to write file: {e}")))?; self.apply_permissions_if_needed(path, operation) } /// Execute token directory creation operation. async fn execute_ensure_token_directory( &self, path: &Path, operation: &Operation, ) -> Result<()> { log::info!("Ensuring token directory: {}", path.display()); fs::create_dir_all(path) .map_err(|e| Error::FileIo(format!("Failed to create token directory: {e}")))?; self.apply_permissions_if_needed(path, operation) } /// Execute OAuth2 authentication flow. async fn execute_oauth_flow( &self, config_root: &str, credential_file: &Option, ) -> Result<()> { if credential_file.is_some() { log::info!("Starting OAuth2 authentication flow"); self.run_oauth_flow(config_root).await } else { log::warn!("Skipping OAuth2 - no credential file available"); Ok(()) } } /// Handle existing file logic (backup or interactive prompt). async fn handle_existing_file( &self, path: &Path, backup_if_exists: bool, operation_name: &str, ) -> Result<()> { if !path.exists() { return Ok(()); } if backup_if_exists { self.create_backup(path) } else if self.interactive { self.prompt_for_overwrite(path, operation_name).await } else { Ok(()) } } /// Prompt user for file overwrite confirmation. async fn prompt_for_overwrite(&self, path: &Path, operation_name: &str) -> Result<()> { let should_overwrite = Confirm::new() .with_prompt(format!("Overwrite existing file {}?", path.display())) .default(false) .interact() .map_err(|e| Error::FileIo(format!("Interactive prompt failed: {e}")))?; if !should_overwrite { log::info!("Skipping {operation_name} due to user choice"); return Ok(()); } self.create_backup(path) } /// Apply file permissions if needed (Unix only). fn apply_permissions_if_needed(&self, path: &Path, operation: &Operation) -> Result<()> { #[cfg(unix)] if let Some(mode) = operation.get_mode() { self.set_permissions(path, mode)?; } Ok(()) } /// Create a timestamped backup of a file. fn create_backup(&self, file_path: &Path) -> Result<()> { let timestamp = Local::now().format("%Y%m%d%H%M%S"); let backup_path = file_path.with_extension(format!("bak-{timestamp}")); log::info!( "Creating backup: {} → {}", file_path.display(), backup_path.display() ); fs::copy(file_path, &backup_path) .map_err(|e| Error::FileIo(format!("Failed to create backup: {e}")))?; Ok(()) } /// Set file permissions (Unix only). #[cfg(unix)] fn set_permissions(&self, path: &Path, mode: u32) -> Result<()> { use std::os::unix::fs::PermissionsExt; let metadata = fs::metadata(path) .map_err(|e| Error::FileIo(format!("Failed to get file metadata: {e}")))?; let mut permissions = metadata.permissions(); permissions.set_mode(mode); fs::set_permissions(path, permissions) .map_err(|e| Error::FileIo(format!("Failed to set file permissions: {e}")))?; log::debug!("Set permissions {:o} on {}", mode, path.display()); Ok(()) } /// Run OAuth2 authentication flow. async fn run_oauth_flow(&self, config_root: &str) -> Result<()> { println!("🔐 Starting OAuth2 authentication..."); println!("This will open your web browser for Gmail authorization."); // Parse config root and build ClientConfig let config_path = parse_config_root(config_root); let client_config = ClientConfig::builder() .with_credential_file(InitDefaults::credential_filename()) .with_config_path(config_path.to_string_lossy().as_ref()) .build(); // Initialize Gmail client which will trigger OAuth flow if needed let client = GmailClient::new_with_config(client_config) .await .map_err(|e| Error::FileIo(format!("OAuth2 authentication failed: {e}")))?; // The client initialization already verified the connection by fetching labels // We can just show some labels to confirm it's working client.show_label(); println!("✅ OAuth2 authentication successful!"); log::info!("OAuth2 tokens generated and cached"); Ok(()) } /// Show completion message and next steps. fn show_completion(&self, config_path: &Path) { println!("🎉 Initialization completed successfully!\n"); println!("📁 Configuration directory: {}", config_path.display()); println!("📄 Files created:"); println!(" - cull-gmail.toml (main configuration)"); if self.skip_rules { println!(" - rules.toml (SKIPPED - expected to be provided externally)"); let rules_dir = self.get_rules_directory(config_path); let rules_path = rules_dir.join(InitDefaults::rules_filename()); println!(" Configured path: {}", rules_path.display()); } else { println!(" - rules.toml (retention rules template)"); } if self.credential_file.is_some() { println!(" - credential.json (OAuth2 credentials)"); println!(" - gmail1/ (OAuth2 token cache)"); } println!(); println!("📋 Next steps:"); if self.credential_file.is_some() { println!(" 1. Test Gmail connection: cull-gmail labels"); if self.skip_rules { println!(" 2. Ensure rules.toml is provided at the configured path"); println!(" 3. Review rules: cull-gmail rules run --dry-run"); println!(" 4. Run rules safely: cull-gmail rules run --dry-run"); println!(" 5. Execute for real: cull-gmail rules run --execute"); } else { println!(" 2. Review rules template: cull-gmail rules run --dry-run"); println!(" 3. Customize rules.toml as needed"); println!(" 4. Run rules safely: cull-gmail rules run --dry-run"); println!(" 5. Execute for real: cull-gmail rules run --execute"); } } else { println!(" 1. Add your OAuth2 credential file to:"); println!(" {}/credential.json", config_path.display()); println!(" 2. Complete setup: cull-gmail init"); if self.skip_rules { println!(" 3. Ensure rules.toml is provided at the configured path"); } println!(" 3. Or get credentials from:"); println!(" https://console.cloud.google.com/apis/credentials"); } println!(); println!("💡 Tips:"); println!(" - All operations use dry-run mode by default for safety"); println!(" - Use --execute flag or set execute=true in config for real actions"); println!(" - See 'cull-gmail --help' for all available commands"); } } impl Operation { /// Get the file mode for this operation (Unix only). #[cfg(unix)] fn get_mode(&self) -> Option { match self { Operation::CreateDir { mode, .. } | Operation::CopyFile { mode, .. } | Operation::WriteFile { mode, .. } | Operation::EnsureTokenDir { mode, .. } => *mode, Operation::RunOAuth2 { .. } => None, } } } #[cfg(test)] mod tests;