Files
cull-gmail/src/cli/init_cli.rs
2025-10-23 11:16:25 +01:00

1098 lines
38 KiB
Rust

//! # 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<Regex> = lazy_regex!(r"^(?P<class>[hrc]):(?P<path>.+)$");
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<PathBuf>,
/// 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<String>,
/// 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<u32>,
},
/// Copy a file from source to destination with optional chmod and backup.
CopyFile {
from: PathBuf,
to: PathBuf,
#[cfg(unix)]
mode: Option<u32>,
backup_if_exists: bool,
},
/// Write content to a file with optional permissions and backup.
WriteFile {
path: PathBuf,
contents: String,
#[cfg(unix)]
mode: Option<u32>,
backup_if_exists: bool,
},
/// Ensure token directory exists with secure permissions.
EnsureTokenDir {
path: PathBuf,
#[cfg(unix)]
mode: Option<u32>,
},
/// Run OAuth2 authentication flow.
RunOAuth2 {
config_root: String,
credential_file: Option<String>,
},
}
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<Option<PathBuf>> {
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::<ConsoleApplicationSecret>(&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<Vec<Operation>> {
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<Operation>, 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<Operation>,
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<Operation>,
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<Operation>,
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<Operation>, 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<Operation>) {
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<String>,
) -> 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<u32> {
match self {
Operation::CreateDir { mode, .. }
| Operation::CopyFile { mode, .. }
| Operation::WriteFile { mode, .. }
| Operation::EnsureTokenDir { mode, .. } => *mode,
Operation::RunOAuth2 { .. } => None,
}
}
}
#[cfg(test)]
mod tests;