feat: add --dry-run mode to Rust implementation

Add comprehensive dry-run functionality to the Rust implementation that allows
users to test their configuration without making any changes to CouchDB:

- Added --dry-run/-n command line flag with clap argument parsing
- Extended CommandLineArgs struct with dry_run field
- Updated bash completion script to include new flag
- Comprehensive dry-run logic throughout sync coordinator:
  - Skip database creation with informative logging
  - Skip sync metadata retrieval and use config fallback
  - Skip deleted message handling in sync mode
  - Skip message and attachment storage with detailed simulation
  - Skip sync metadata updates with summary information
- Enhanced summary output to clearly indicate dry-run vs normal mode
- Updated all tests to include new dry_run field
- Maintains all IMAP operations for realistic mail discovery testing

This brings the Rust implementation to feature parity with the Go version
for safe configuration testing as identified in ANALYSIS.md.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2025-08-03 18:26:01 +02:00
commit 322fb094a5
4 changed files with 153 additions and 76 deletions

View file

@ -28,6 +28,11 @@ pub fn parse_command_line() -> CommandLineArgs {
.help("Maximum number of messages to process per mailbox per run (0 = no limit)") .help("Maximum number of messages to process per mailbox per run (0 = no limit)")
.value_parser(clap::value_parser!(u32)) .value_parser(clap::value_parser!(u32))
.action(ArgAction::Set)) .action(ArgAction::Set))
.arg(Arg::new("dry-run")
.short('n')
.long("dry-run")
.help("Show what would be done without making any changes")
.action(ArgAction::SetTrue))
.arg(Arg::new("generate-bash-completion") .arg(Arg::new("generate-bash-completion")
.long("generate-bash-completion") .long("generate-bash-completion")
.help("Generate bash completion script and exit") .help("Generate bash completion script and exit")
@ -44,6 +49,7 @@ pub fn parse_command_line() -> CommandLineArgs {
CommandLineArgs { CommandLineArgs {
config_path: matches.get_one::<String>("config").map(|s| s.clone()), config_path: matches.get_one::<String>("config").map(|s| s.clone()),
max_messages: matches.get_one::<u32>("max-messages").copied(), max_messages: matches.get_one::<u32>("max-messages").copied(),
dry_run: matches.get_flag("dry-run"),
generate_bash_completion: matches.get_flag("generate-bash-completion"), generate_bash_completion: matches.get_flag("generate-bash-completion"),
help: false, // Using clap's built-in help help: false, // Using clap's built-in help
} }
@ -83,7 +89,7 @@ _{}_completions() {{
if [[ $cur == -* ]]; then if [[ $cur == -* ]]; then
# Complete with available options # Complete with available options
local opts="-c --config -m --max-messages -h --help --generate-bash-completion" local opts="-c --config -m --max-messages -n --dry-run -h --help --generate-bash-completion"
COMPREPLY=($(compgen -W "$opts" -- "$cur")) COMPREPLY=($(compgen -W "$opts" -- "$cur"))
return return
fi fi

View file

@ -107,6 +107,7 @@ impl MailSource {
pub struct CommandLineArgs { pub struct CommandLineArgs {
pub config_path: Option<String>, pub config_path: Option<String>,
pub max_messages: Option<u32>, pub max_messages: Option<u32>,
pub dry_run: bool,
pub generate_bash_completion: bool, pub generate_bash_completion: bool,
pub help: bool, pub help: bool,
} }
@ -283,6 +284,7 @@ mod tests {
let args = CommandLineArgs { let args = CommandLineArgs {
config_path: None, config_path: None,
max_messages: None, max_messages: None,
dry_run: false,
generate_bash_completion: false, generate_bash_completion: false,
help: false, help: false,
}; };

View file

@ -36,6 +36,10 @@ async fn run(args: mail2couch::config::CommandLineArgs) -> Result<()> {
info!("Maximum messages per mailbox: unlimited"); info!("Maximum messages per mailbox: unlimited");
} }
if args.dry_run {
info!("🔍 DRY-RUN MODE: No changes will be made to CouchDB");
}
// Display configuration summary // Display configuration summary
print_config_summary(&config); print_config_summary(&config);

View file

@ -119,8 +119,12 @@ impl SyncCoordinator {
let db_name = generate_database_name(&source.name, &source.user); let db_name = generate_database_name(&source.name, &source.user);
info!("Using database: {}", db_name); info!("Using database: {}", db_name);
// Create database if it doesn't exist // Create database if it doesn't exist (skip in dry-run mode)
if !self.args.dry_run {
self.couch_client.create_database(&db_name).await?; self.couch_client.create_database(&db_name).await?;
} else {
info!("🔍 DRY-RUN: Would create database {}", db_name);
}
// Connect to IMAP server // Connect to IMAP server
let mut imap_client = ImapClient::connect(source.clone()).await?; let mut imap_client = ImapClient::connect(source.clone()).await?;
@ -212,8 +216,9 @@ impl SyncCoordinator {
let mailbox_info = imap_client.select_mailbox(mailbox).await?; let mailbox_info = imap_client.select_mailbox(mailbox).await?;
debug!("Selected mailbox {}: {} messages", mailbox, mailbox_info.exists); debug!("Selected mailbox {}: {} messages", mailbox, mailbox_info.exists);
// Get last sync metadata // Get last sync metadata (skip in dry-run mode)
let since_date = match self.couch_client.get_sync_metadata(db_name, mailbox).await { let since_date = if !self.args.dry_run {
match self.couch_client.get_sync_metadata(db_name, mailbox).await {
Ok(metadata) => { Ok(metadata) => {
info!(" Found sync metadata, last sync: {}", metadata.last_sync_time); info!(" Found sync metadata, last sync: {}", metadata.last_sync_time);
Some(metadata.last_sync_time) Some(metadata.last_sync_time)
@ -228,6 +233,16 @@ impl SyncCoordinator {
.ok() .ok()
}) })
} }
}
} else {
info!(" 🔍 DRY-RUN: Would check for sync metadata");
// In dry-run mode, use config since date if available
source.message_filter.since.as_ref()
.and_then(|since_str| {
DateTime::parse_from_str(&format!("{} 00:00:00 +0000", since_str), "%Y-%m-%d %H:%M:%S %z")
.map(|dt| dt.with_timezone(&Utc))
.ok()
})
}; };
// Search for messages using server-side IMAP SEARCH with keyword filtering when possible // Search for messages using server-side IMAP SEARCH with keyword filtering when possible
@ -257,9 +272,10 @@ impl SyncCoordinator {
}; };
info!(" Found {} messages matching search criteria", message_uids.len()); info!(" Found {} messages matching search criteria", message_uids.len());
// Handle sync mode - check for deleted messages // Handle sync mode - check for deleted messages (skip in dry-run mode)
let mut messages_deleted = 0; let mut messages_deleted = 0;
if source.mode == "sync" { if source.mode == "sync" {
if !self.args.dry_run {
messages_deleted = self.handle_deleted_messages(db_name, mailbox, &message_uids).await messages_deleted = self.handle_deleted_messages(db_name, mailbox, &message_uids).await
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
warn!(" Failed to handle deleted messages: {}", e); warn!(" Failed to handle deleted messages: {}", e);
@ -269,6 +285,9 @@ impl SyncCoordinator {
if messages_deleted > 0 { if messages_deleted > 0 {
info!(" 🗑️ Deleted {} messages that no longer exist on server", messages_deleted); info!(" 🗑️ Deleted {} messages that no longer exist on server", messages_deleted);
} }
} else {
info!(" 🔍 DRY-RUN: Would check for deleted messages in sync mode");
}
} }
if message_uids.is_empty() { if message_uids.is_empty() {
@ -312,7 +331,8 @@ impl SyncCoordinator {
// Extract UID before moving the document // Extract UID before moving the document
let uid_str = mail_doc.source_uid.clone(); let uid_str = mail_doc.source_uid.clone();
// Store the message document first // Store the message document first (skip in dry-run mode)
if !self.args.dry_run {
match self.couch_client.store_mail_document(db_name, mail_doc).await { match self.couch_client.store_mail_document(db_name, mail_doc).await {
Ok(doc_id) => { Ok(doc_id) => {
messages_stored += 1; messages_stored += 1;
@ -347,10 +367,26 @@ impl SyncCoordinator {
messages_skipped += 1; messages_skipped += 1;
} }
} }
} else {
// In dry-run mode, simulate successful storage
messages_stored += 1;
debug!(" 🔍 DRY-RUN: Would store message {} (Subject: {})",
uid_str, mail_doc.subject);
if !attachments.is_empty() {
debug!(" 🔍 DRY-RUN: Would store {} attachments", attachments.len());
} }
// Update sync metadata // Parse UID from source_uid
if let Ok(uid) = uid_str.parse::<u32>() {
last_uid = Some(last_uid.map_or(uid, |prev: u32| prev.max(uid)));
}
}
}
// Update sync metadata (skip in dry-run mode)
if let Some(uid) = last_uid { if let Some(uid) = last_uid {
if !self.args.dry_run {
let sync_metadata = SyncMetadata::new( let sync_metadata = SyncMetadata::new(
mailbox.to_string(), mailbox.to_string(),
start_time, start_time,
@ -361,6 +397,9 @@ impl SyncCoordinator {
if let Err(e) = self.couch_client.store_sync_metadata(db_name, &sync_metadata).await { if let Err(e) = self.couch_client.store_sync_metadata(db_name, &sync_metadata).await {
warn!(" Failed to store sync metadata: {}", e); warn!(" Failed to store sync metadata: {}", e);
} }
} else {
info!(" 🔍 DRY-RUN: Would update sync metadata (last UID: {}, {} messages)", uid, messages_stored);
}
} }
Ok(MailboxSyncResult { Ok(MailboxSyncResult {
@ -422,7 +461,11 @@ impl SyncCoordinator {
/// Print summary of sync results /// Print summary of sync results
pub fn print_sync_summary(&self, results: &[SourceSyncResult]) { pub fn print_sync_summary(&self, results: &[SourceSyncResult]) {
if self.args.dry_run {
info!("\n🔍 DRY-RUN completed!");
} else {
info!("\n🎉 Synchronization completed!"); info!("\n🎉 Synchronization completed!");
}
info!("{}", "=".repeat(50)); info!("{}", "=".repeat(50));
let mut total_sources = 0; let mut total_sources = 0;
@ -434,6 +477,15 @@ impl SyncCoordinator {
total_mailboxes += result.mailboxes_processed; total_mailboxes += result.mailboxes_processed;
total_messages += result.total_messages; total_messages += result.total_messages;
if self.args.dry_run {
info!(
"📧 {}: {} mailboxes, {} messages found (database: {})",
result.source_name,
result.mailboxes_processed,
result.total_messages,
result.database
);
} else {
info!( info!(
"📧 {}: {} mailboxes, {} messages (database: {})", "📧 {}: {} mailboxes, {} messages (database: {})",
result.source_name, result.source_name,
@ -442,16 +494,28 @@ impl SyncCoordinator {
result.database result.database
); );
} }
}
info!("{}", "=".repeat(50)); info!("{}", "=".repeat(50));
if self.args.dry_run {
info!(
"📊 DRY-RUN Total: {} sources, {} mailboxes, {} messages found",
total_sources, total_mailboxes, total_messages
);
} else {
info!( info!(
"📊 Total: {} sources, {} mailboxes, {} messages", "📊 Total: {} sources, {} mailboxes, {} messages",
total_sources, total_mailboxes, total_messages total_sources, total_mailboxes, total_messages
); );
}
if let Some(max) = self.args.max_messages { if let Some(max) = self.args.max_messages {
info!("⚠️ Message limit was applied: {} per mailbox", max); info!("⚠️ Message limit was applied: {} per mailbox", max);
} }
if self.args.dry_run {
info!("🔍 No changes were made to CouchDB");
}
} }
} }
@ -493,6 +557,7 @@ mod tests {
let args = CommandLineArgs { let args = CommandLineArgs {
config_path: None, config_path: None,
max_messages: Some(10), max_messages: Some(10),
dry_run: false,
generate_bash_completion: false, generate_bash_completion: false,
help: false, help: false,
}; };