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:
parent
61ba952155
commit
322fb094a5
4 changed files with 153 additions and 76 deletions
|
|
@ -28,6 +28,11 @@ pub fn parse_command_line() -> CommandLineArgs {
|
|||
.help("Maximum number of messages to process per mailbox per run (0 = no limit)")
|
||||
.value_parser(clap::value_parser!(u32))
|
||||
.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")
|
||||
.long("generate-bash-completion")
|
||||
.help("Generate bash completion script and exit")
|
||||
|
|
@ -44,6 +49,7 @@ pub fn parse_command_line() -> CommandLineArgs {
|
|||
CommandLineArgs {
|
||||
config_path: matches.get_one::<String>("config").map(|s| s.clone()),
|
||||
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"),
|
||||
help: false, // Using clap's built-in help
|
||||
}
|
||||
|
|
@ -83,7 +89,7 @@ _{}_completions() {{
|
|||
|
||||
if [[ $cur == -* ]]; then
|
||||
# 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"))
|
||||
return
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ impl MailSource {
|
|||
pub struct CommandLineArgs {
|
||||
pub config_path: Option<String>,
|
||||
pub max_messages: Option<u32>,
|
||||
pub dry_run: bool,
|
||||
pub generate_bash_completion: bool,
|
||||
pub help: bool,
|
||||
}
|
||||
|
|
@ -283,6 +284,7 @@ mod tests {
|
|||
let args = CommandLineArgs {
|
||||
config_path: None,
|
||||
max_messages: None,
|
||||
dry_run: false,
|
||||
generate_bash_completion: false,
|
||||
help: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ async fn run(args: mail2couch::config::CommandLineArgs) -> Result<()> {
|
|||
info!("Maximum messages per mailbox: unlimited");
|
||||
}
|
||||
|
||||
if args.dry_run {
|
||||
info!("🔍 DRY-RUN MODE: No changes will be made to CouchDB");
|
||||
}
|
||||
|
||||
// Display configuration summary
|
||||
print_config_summary(&config);
|
||||
|
||||
|
|
|
|||
213
rust/src/sync.rs
213
rust/src/sync.rs
|
|
@ -119,8 +119,12 @@ impl SyncCoordinator {
|
|||
let db_name = generate_database_name(&source.name, &source.user);
|
||||
info!("Using database: {}", db_name);
|
||||
|
||||
// Create database if it doesn't exist
|
||||
self.couch_client.create_database(&db_name).await?;
|
||||
// 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?;
|
||||
} else {
|
||||
info!("🔍 DRY-RUN: Would create database {}", db_name);
|
||||
}
|
||||
|
||||
// Connect to IMAP server
|
||||
let mut imap_client = ImapClient::connect(source.clone()).await?;
|
||||
|
|
@ -212,22 +216,33 @@ impl SyncCoordinator {
|
|||
let mailbox_info = imap_client.select_mailbox(mailbox).await?;
|
||||
debug!("Selected mailbox {}: {} messages", mailbox, mailbox_info.exists);
|
||||
|
||||
// Get last sync metadata
|
||||
let since_date = match self.couch_client.get_sync_metadata(db_name, mailbox).await {
|
||||
Ok(metadata) => {
|
||||
info!(" Found sync metadata, last sync: {}", metadata.last_sync_time);
|
||||
Some(metadata.last_sync_time)
|
||||
}
|
||||
Err(_) => {
|
||||
info!(" No sync metadata found, performing full sync");
|
||||
// Parse since date from message filter if provided
|
||||
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()
|
||||
})
|
||||
// Get last sync metadata (skip in dry-run mode)
|
||||
let since_date = if !self.args.dry_run {
|
||||
match self.couch_client.get_sync_metadata(db_name, mailbox).await {
|
||||
Ok(metadata) => {
|
||||
info!(" Found sync metadata, last sync: {}", metadata.last_sync_time);
|
||||
Some(metadata.last_sync_time)
|
||||
}
|
||||
Err(_) => {
|
||||
info!(" No sync metadata found, performing full sync");
|
||||
// Parse since date from message filter if provided
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
} 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
|
||||
|
|
@ -257,17 +272,21 @@ impl SyncCoordinator {
|
|||
};
|
||||
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;
|
||||
if source.mode == "sync" {
|
||||
messages_deleted = self.handle_deleted_messages(db_name, mailbox, &message_uids).await
|
||||
.unwrap_or_else(|e| {
|
||||
warn!(" Failed to handle deleted messages: {}", e);
|
||||
0
|
||||
});
|
||||
|
||||
if messages_deleted > 0 {
|
||||
info!(" 🗑️ Deleted {} messages that no longer exist on server", messages_deleted);
|
||||
if !self.args.dry_run {
|
||||
messages_deleted = self.handle_deleted_messages(db_name, mailbox, &message_uids).await
|
||||
.unwrap_or_else(|e| {
|
||||
warn!(" Failed to handle deleted messages: {}", e);
|
||||
0
|
||||
});
|
||||
|
||||
if messages_deleted > 0 {
|
||||
info!(" 🗑️ Deleted {} messages that no longer exist on server", messages_deleted);
|
||||
}
|
||||
} else {
|
||||
info!(" 🔍 DRY-RUN: Would check for deleted messages in sync mode");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,54 +331,74 @@ impl SyncCoordinator {
|
|||
// Extract UID before moving the document
|
||||
let uid_str = mail_doc.source_uid.clone();
|
||||
|
||||
// Store the message document first
|
||||
match self.couch_client.store_mail_document(db_name, mail_doc).await {
|
||||
Ok(doc_id) => {
|
||||
messages_stored += 1;
|
||||
|
||||
// Store attachments if any exist
|
||||
if !attachments.is_empty() {
|
||||
for (filename, content_type, data) in attachments {
|
||||
match self.couch_client.store_attachment(
|
||||
db_name,
|
||||
&doc_id,
|
||||
&filename,
|
||||
&content_type,
|
||||
&data,
|
||||
).await {
|
||||
Ok(_) => {
|
||||
debug!(" Stored attachment: {}", filename);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(" Failed to store attachment {}: {}", filename, e);
|
||||
// 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 {
|
||||
Ok(doc_id) => {
|
||||
messages_stored += 1;
|
||||
|
||||
// Store attachments if any exist
|
||||
if !attachments.is_empty() {
|
||||
for (filename, content_type, data) in attachments {
|
||||
match self.couch_client.store_attachment(
|
||||
db_name,
|
||||
&doc_id,
|
||||
&filename,
|
||||
&content_type,
|
||||
&data,
|
||||
).await {
|
||||
Ok(_) => {
|
||||
debug!(" Stored attachment: {}", filename);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(" Failed to store attachment {}: {}", filename, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
|
||||
// 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)));
|
||||
Err(e) => {
|
||||
warn!(" Failed to store message {}: {}", uid_str, e);
|
||||
messages_skipped += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(" Failed to store message {}: {}", uid_str, e);
|
||||
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());
|
||||
}
|
||||
|
||||
// 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
|
||||
// Update sync metadata (skip in dry-run mode)
|
||||
if let Some(uid) = last_uid {
|
||||
let sync_metadata = SyncMetadata::new(
|
||||
mailbox.to_string(),
|
||||
start_time,
|
||||
uid,
|
||||
messages_stored,
|
||||
);
|
||||
if !self.args.dry_run {
|
||||
let sync_metadata = SyncMetadata::new(
|
||||
mailbox.to_string(),
|
||||
start_time,
|
||||
uid,
|
||||
messages_stored,
|
||||
);
|
||||
|
||||
if let Err(e) = self.couch_client.store_sync_metadata(db_name, &sync_metadata).await {
|
||||
warn!(" Failed to store sync metadata: {}", e);
|
||||
if let Err(e) = self.couch_client.store_sync_metadata(db_name, &sync_metadata).await {
|
||||
warn!(" Failed to store sync metadata: {}", e);
|
||||
}
|
||||
} else {
|
||||
info!(" 🔍 DRY-RUN: Would update sync metadata (last UID: {}, {} messages)", uid, messages_stored);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -422,7 +461,11 @@ impl SyncCoordinator {
|
|||
|
||||
/// Print summary of sync results
|
||||
pub fn print_sync_summary(&self, results: &[SourceSyncResult]) {
|
||||
info!("\n🎉 Synchronization completed!");
|
||||
if self.args.dry_run {
|
||||
info!("\n🔍 DRY-RUN completed!");
|
||||
} else {
|
||||
info!("\n🎉 Synchronization completed!");
|
||||
}
|
||||
info!("{}", "=".repeat(50));
|
||||
|
||||
let mut total_sources = 0;
|
||||
|
|
@ -434,24 +477,45 @@ impl SyncCoordinator {
|
|||
total_mailboxes += result.mailboxes_processed;
|
||||
total_messages += result.total_messages;
|
||||
|
||||
info!(
|
||||
"📧 {}: {} mailboxes, {} messages (database: {})",
|
||||
result.source_name,
|
||||
result.mailboxes_processed,
|
||||
result.total_messages,
|
||||
result.database
|
||||
);
|
||||
if self.args.dry_run {
|
||||
info!(
|
||||
"📧 {}: {} mailboxes, {} messages found (database: {})",
|
||||
result.source_name,
|
||||
result.mailboxes_processed,
|
||||
result.total_messages,
|
||||
result.database
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"📧 {}: {} mailboxes, {} messages (database: {})",
|
||||
result.source_name,
|
||||
result.mailboxes_processed,
|
||||
result.total_messages,
|
||||
result.database
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
info!("{}", "=".repeat(50));
|
||||
info!(
|
||||
"📊 Total: {} sources, {} mailboxes, {} messages",
|
||||
total_sources, total_mailboxes, total_messages
|
||||
);
|
||||
if self.args.dry_run {
|
||||
info!(
|
||||
"📊 DRY-RUN Total: {} sources, {} mailboxes, {} messages found",
|
||||
total_sources, total_mailboxes, total_messages
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"📊 Total: {} sources, {} mailboxes, {} messages",
|
||||
total_sources, total_mailboxes, total_messages
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(max) = self.args.max_messages {
|
||||
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 {
|
||||
config_path: None,
|
||||
max_messages: Some(10),
|
||||
dry_run: false,
|
||||
generate_bash_completion: false,
|
||||
help: false,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue