🏗️ feat(init): implement plan and apply operations

- Add comprehensive Operation enum for file/directory operations
- Implement path resolution with h:, c:, r: prefix support
- Create default config and rules file templates
- Add interactive prompts for credential file setup
- Support dry-run mode with detailed operation preview
- Include progress bar for operation execution
- Implement secure file permissions (0600 for credentials, 0700 for tokens)
- Add timestamped backup functionality for existing files
- Support force overwrite and interactive conflict resolution
This commit is contained in:
Jeremiah Russell
2025-10-21 11:29:19 +01:00
committed by Jeremiah Russell
parent fd70ef9511
commit 0a047dd547

View File

@@ -48,8 +48,35 @@
//! - **Backup Safety**: Existing files are backed up with timestamps before overwriting //! - **Backup Safety**: Existing files are backed up with timestamps before overwriting
//! - **Interactive Confirmation**: Prompts for confirmation before overwriting existing files //! - **Interactive Confirmation**: Prompts for confirmation before overwriting existing files
use chrono::Local;
use clap::Parser; use clap::Parser;
use std::path::PathBuf; 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.
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. /// Initialize cull-gmail configuration, credentials, and OAuth2 tokens.
/// ///
@@ -135,10 +162,7 @@ pub struct InitCli {
/// Enables preview mode where all planned operations are displayed /// Enables preview mode where all planned operations are displayed
/// but no files are created, modified, or removed. OAuth2 authentication /// but no files are created, modified, or removed. OAuth2 authentication
/// flow is also skipped in dry-run mode. /// flow is also skipped in dry-run mode.
#[arg( #[arg(long = "dry-run", help = "Preview actions without making changes")]
long = "dry-run",
help = "Preview actions without making changes"
)]
pub dry_run: bool, pub dry_run: bool,
/// Enable interactive prompts and confirmations. /// Enable interactive prompts and confirmations.
@@ -154,6 +178,167 @@ pub struct InitCli {
pub interactive: bool, pub interactive: 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"
"#;
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 { impl InitCli {
/// Execute the initialization command. /// Execute the initialization command.
/// ///
@@ -182,16 +367,457 @@ impl InitCli {
/// - Configuration conflicts without force or interactive resolution /// - Configuration conflicts without force or interactive resolution
/// - OAuth2 authentication failures /// - OAuth2 authentication failures
/// - Network connectivity issues during authentication /// - Network connectivity issues during authentication
pub async fn run(&self) -> cull_gmail::Result<()> { pub async fn run(&self) -> Result<()> {
log::info!("Starting cull-gmail initialization"); log::info!("Starting cull-gmail initialization");
if self.dry_run { if self.dry_run {
println!("DRY RUN: No changes will be made"); println!("🔍 DRY RUN: No changes will be made\n");
} }
// TODO: Implement initialization logic // Resolve configuration directory path
println!("Init command called with options: {self:?}"); 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(()) 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
if !config_path.exists() {
operations.push(Operation::CreateDir {
path: config_path.to_path_buf(),
#[cfg(unix)]
mode: Some(0o755),
});
}
// 2. Copy credential file if provided
if let Some(cred_file) = credential_file {
let dest_path = config_path.join(InitDefaults::credential_filename());
let backup_needed = dest_path.exists() && !self.force;
if dest_path.exists() && !self.force && !self.interactive {
return Err(Error::FileIo(format!(
"Credential file already exists: {}\nUse --force to overwrite or --interactive for prompts",
dest_path.display()
)));
}
operations.push(Operation::CopyFile {
from: cred_file.clone(),
to: dest_path,
#[cfg(unix)]
mode: Some(0o600),
backup_if_exists: backup_needed || self.force,
});
}
// 3. Write config file
let config_file_path = config_path.join(InitDefaults::config_filename());
let config_backup_needed = config_file_path.exists() && !self.force;
if config_file_path.exists() && !self.force && !self.interactive {
return Err(Error::FileIo(format!(
"Configuration file already exists: {}\nUse --force to overwrite or --interactive for prompts",
config_file_path.display()
)));
}
operations.push(Operation::WriteFile {
path: config_file_path,
contents: InitDefaults::CONFIG_FILE_CONTENT.to_string(),
#[cfg(unix)]
mode: Some(0o644),
backup_if_exists: config_backup_needed || self.force,
});
// 4. Write rules file
let rules_file_path = config_path.join(InitDefaults::rules_filename());
let rules_backup_needed = rules_file_path.exists() && !self.force;
if rules_file_path.exists() && !self.force && !self.interactive {
return Err(Error::FileIo(format!(
"Rules file already exists: {}\nUse --force to overwrite or --interactive for prompts",
rules_file_path.display()
)));
}
operations.push(Operation::WriteFile {
path: rules_file_path,
contents: InitDefaults::RULES_FILE_CONTENT.to_string(),
#[cfg(unix)]
mode: Some(0o644),
backup_if_exists: rules_backup_needed || self.force,
});
// 5. Ensure token directory exists
let token_dir = config_path.join(InitDefaults::token_dir_name());
operations.push(Operation::EnsureTokenDir {
path: token_dir,
#[cfg(unix)]
mode: Some(0o700),
});
// 6. Run OAuth2 if we have credentials
if credential_file.is_some() {
operations.push(Operation::RunOAuth2 {
config_root: self.config_dir.clone(),
credential_file: Some(InitDefaults::credential_filename().to_string()),
});
}
Ok(operations)
}
/// 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!();
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, .. } => {
log::info!("Creating directory: {}", path.display());
fs::create_dir_all(path)
.map_err(|e| Error::FileIo(format!("Failed to create directory: {}", e)))?;
#[cfg(unix)]
if let Some(mode) = operation.get_mode() {
self.set_permissions(path, mode)?;
}
}
Operation::CopyFile {
from,
to,
backup_if_exists,
..
} => {
if *backup_if_exists && to.exists() {
self.create_backup(to)?;
} else if to.exists() && self.interactive {
let should_overwrite = Confirm::new()
.with_prompt(&format!("Overwrite existing file {}?", to.display()))
.default(false)
.interact()
.map_err(|e| Error::FileIo(format!("Interactive prompt failed: {}", e)))?;
if !should_overwrite {
log::info!("Skipping file copy due to user choice");
return Ok(());
}
self.create_backup(to)?;
}
log::info!("Copying file: {} → {}", from.display(), to.display());
fs::copy(from, to)
.map_err(|e| Error::FileIo(format!("Failed to copy file: {}", e)))?;
#[cfg(unix)]
if let Some(mode) = operation.get_mode() {
self.set_permissions(to, mode)?;
}
}
Operation::WriteFile {
path,
contents,
backup_if_exists,
..
} => {
if *backup_if_exists && path.exists() {
self.create_backup(path)?;
} else if path.exists() && self.interactive {
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 file write due to user choice");
return Ok(());
}
self.create_backup(path)?;
}
log::info!("Writing file: {}", path.display());
fs::write(path, contents)
.map_err(|e| Error::FileIo(format!("Failed to write file: {}", e)))?;
#[cfg(unix)]
if let Some(mode) = operation.get_mode() {
self.set_permissions(path, mode)?;
}
}
Operation::EnsureTokenDir { path, .. } => {
log::info!("Ensuring token directory: {}", path.display());
fs::create_dir_all(path).map_err(|e| {
Error::FileIo(format!("Failed to create token directory: {}", e))
})?;
#[cfg(unix)]
if let Some(mode) = operation.get_mode() {
self.set_permissions(path, mode)?;
}
}
Operation::RunOAuth2 {
config_root,
credential_file,
} => {
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(())
}
/// 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)");
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");
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");
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,
}
}
} }