From 61ba952155df0b025d487fa4fe52eb7e4d46b645 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Sun, 3 Aug 2025 18:21:27 +0200 Subject: [PATCH] feat: add --dry-run mode to Go implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive dry-run functionality that allows users to test their configuration without making any changes to CouchDB. The feature includes: - New --dry-run/-n command line flag with help and completion support - Skips all CouchDB write operations while preserving IMAP operations - Provides detailed logging of what would be done in normal mode - Shows sample message data and metadata updates that would occur - Maintains all existing functionality when dry-run is disabled This addresses the critical usability need identified in ANALYSIS.md for safe configuration testing before making database changes. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- go/config/config.go | 7 ++- go/main.go | 150 +++++++++++++++++++++++++++----------------- 2 files changed, 100 insertions(+), 57 deletions(-) diff --git a/go/config/config.go b/go/config/config.go index ddb3689..713e73d 100644 --- a/go/config/config.go +++ b/go/config/config.go @@ -86,6 +86,7 @@ func (ms *MailSource) IsArchiveMode() bool { type CommandLineArgs struct { ConfigPath string MaxMessages int + DryRun bool } // ParseCommandLine parses command line arguments using GNU-style options @@ -95,6 +96,7 @@ func ParseCommandLine() *CommandLineArgs { // Define long options with -- and short options with - pflag.StringVarP(&args.ConfigPath, "config", "c", "", "Path to configuration file") pflag.IntVarP(&args.MaxMessages, "max-messages", "m", 0, "Maximum number of messages to process per mailbox per run (0 = no limit)") + pflag.BoolVarP(&args.DryRun, "dry-run", "n", false, "Show what would be done without making any changes") // Add utility options pflag.BoolP("help", "h", false, "Show help message") @@ -146,7 +148,7 @@ _%s_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 @@ -217,5 +219,8 @@ func LoadConfigWithDiscovery(args *CommandLineArgs) (*Config, error) { } else { fmt.Printf("Maximum messages per mailbox: unlimited\n") } + if args.DryRun { + fmt.Printf("DRY-RUN MODE: No changes will be made to CouchDB\n") + } return LoadConfig(configPath) } diff --git a/go/main.go b/go/main.go index 399d67c..1523b09 100644 --- a/go/main.go +++ b/go/main.go @@ -19,10 +19,14 @@ func main() { log.Fatalf("Failed to load configuration: %v", err) } - // Initialize CouchDB client - couchClient, err := couch.NewClient(&cfg.CouchDb) - if err != nil { - log.Fatalf("Failed to create CouchDB client: %v", err) + // Initialize CouchDB client (skip in dry-run mode) + var couchClient *couch.Client + if !args.DryRun { + var err error + couchClient, err = couch.NewClient(&cfg.CouchDb) + if err != nil { + log.Fatalf("Failed to create CouchDB client: %v", err) + } } fmt.Printf("Found %d mail source(s) to process.\n", len(cfg.MailSources)) @@ -34,21 +38,25 @@ func main() { // Generate per-account database name dbName := couch.GenerateAccountDBName(source.Name, source.User) - // Ensure the account-specific database exists - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - err = couchClient.EnsureDB(ctx, dbName) - cancel() + // Ensure the account-specific database exists (skip in dry-run mode) + if !args.DryRun { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err = couchClient.EnsureDB(ctx, dbName) + cancel() - if err != nil { - log.Printf("Could not ensure CouchDB database '%s' exists (is it running?): %v", dbName, err) - continue + if err != nil { + log.Printf("Could not ensure CouchDB database '%s' exists (is it running?): %v", dbName, err) + continue + } else { + fmt.Printf("CouchDB database '%s' is ready for account: %s\n", dbName, source.Name) + } } else { - fmt.Printf("CouchDB database '%s' is ready for account: %s\n", dbName, source.Name) + fmt.Printf("DRY-RUN: Would ensure CouchDB database '%s' exists for account: %s\n", dbName, source.Name) } fmt.Printf(" - Processing source: %s\n", source.Name) if source.Protocol == "imap" { - err := processImapSource(&source, couchClient, dbName, args.MaxMessages) + err := processImapSource(&source, couchClient, dbName, args.MaxMessages, args.DryRun) if err != nil { log.Printf(" ERROR: Failed to process IMAP source %s: %v", source.Name, err) } @@ -56,7 +64,7 @@ func main() { } } -func processImapSource(source *config.MailSource, couchClient *couch.Client, dbName string, maxMessages int) error { +func processImapSource(source *config.MailSource, couchClient *couch.Client, dbName string, maxMessages int, dryRun bool) error { fmt.Printf(" Connecting to IMAP server: %s:%d\n", source.Host, source.Port) imapClient, err := mail.NewImapClient(source) if err != nil { @@ -92,13 +100,17 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN for _, mailbox := range mailboxes { fmt.Printf(" Processing mailbox: %s (mode: %s)\n", mailbox, source.Mode) - // Get sync metadata to determine incremental sync date - syncCtx, syncCancel := context.WithTimeout(context.Background(), 10*time.Second) - syncMetadata, err := couchClient.GetSyncMetadata(syncCtx, dbName, mailbox) - syncCancel() - if err != nil { - log.Printf(" ERROR: Failed to get sync metadata for %s: %v", mailbox, err) - continue + // Get sync metadata to determine incremental sync date (skip in dry-run mode) + var syncMetadata *couch.SyncMetadata + if !dryRun { + syncCtx, syncCancel := context.WithTimeout(context.Background(), 10*time.Second) + var err error + syncMetadata, err = couchClient.GetSyncMetadata(syncCtx, dbName, mailbox) + syncCancel() + if err != nil { + log.Printf(" ERROR: Failed to get sync metadata for %s: %v", mailbox, err) + continue + } } // Determine the since date for incremental sync @@ -114,7 +126,11 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN if sinceDate != nil { fmt.Printf(" First sync since: %s (from config)\n", sinceDate.Format("2006-01-02")) } else { - fmt.Printf(" First full sync (no date filter)\n") + if dryRun { + fmt.Printf(" DRY-RUN: Would perform first full sync (no date filter)\n") + } else { + fmt.Printf(" First full sync (no date filter)\n") + } } } @@ -125,13 +141,18 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN continue } - // Perform sync/archive logic - mailboxSyncCtx, mailboxSyncCancel := context.WithTimeout(context.Background(), 30*time.Second) - err = couchClient.SyncMailbox(mailboxSyncCtx, dbName, mailbox, currentUIDs, source.IsSyncMode()) - mailboxSyncCancel() - if err != nil { - log.Printf(" ERROR: Failed to sync mailbox %s: %v", mailbox, err) - continue + // Perform sync/archive logic (skip in dry-run mode) + if !dryRun { + mailboxSyncCtx, mailboxSyncCancel := context.WithTimeout(context.Background(), 30*time.Second) + err = couchClient.SyncMailbox(mailboxSyncCtx, dbName, mailbox, currentUIDs, source.IsSyncMode()) + mailboxSyncCancel() + if err != nil { + log.Printf(" ERROR: Failed to sync mailbox %s: %v", mailbox, err) + continue + } + } else { + fmt.Printf(" DRY-RUN: Would sync mailbox %s with %d current UIDs (mode: %s)\n", + mailbox, len(currentUIDs), source.Mode) } if len(messages) == 0 { @@ -149,23 +170,32 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN docs = append(docs, doc) } - // Store messages in CouchDB with attachments - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + // Store messages in CouchDB with attachments (skip in dry-run mode) stored := 0 - for i, doc := range docs { - err := couchClient.StoreMessage(ctx, dbName, doc, messages[i]) - if err != nil { - log.Printf(" ERROR: Failed to store message %s: %v", doc.ID, err) - } else { - stored++ + if !dryRun { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + for i, doc := range docs { + err := couchClient.StoreMessage(ctx, dbName, doc, messages[i]) + if err != nil { + log.Printf(" ERROR: Failed to store message %s: %v", doc.ID, err) + } else { + stored++ + } + } + cancel() + fmt.Printf(" Stored %d/%d messages from %s\n", stored, len(messages), mailbox) + } else { + stored = len(messages) // In dry-run, assume all would be stored + fmt.Printf(" DRY-RUN: Would store %d messages from %s\n", len(messages), mailbox) + // Show sample of what would be stored + if len(docs) > 0 { + fmt.Printf(" DRY-RUN: Sample message ID: %s (Subject: %s)\n", + docs[0].ID, docs[0].Subject) } } - cancel() - - fmt.Printf(" Stored %d/%d messages from %s\n", stored, len(messages), mailbox) totalStored += stored - // Update sync metadata after successful processing + // Update sync metadata after successful processing (skip in dry-run mode) if len(messages) > 0 { // Find the highest UID processed var maxUID uint32 @@ -175,26 +205,34 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN } } - // Create/update sync metadata - newMetadata := &couch.SyncMetadata{ - Mailbox: mailbox, - LastSyncTime: time.Now(), - LastMessageUID: maxUID, - MessageCount: stored, - } + if !dryRun { + // Create/update sync metadata + newMetadata := &couch.SyncMetadata{ + Mailbox: mailbox, + LastSyncTime: time.Now(), + LastMessageUID: maxUID, + MessageCount: stored, + } - // Store sync metadata - metadataCtx, metadataCancel := context.WithTimeout(context.Background(), 10*time.Second) - err = couchClient.StoreSyncMetadata(metadataCtx, dbName, newMetadata) - metadataCancel() - if err != nil { - log.Printf(" WARNING: Failed to store sync metadata for %s: %v", mailbox, err) + // Store sync metadata + metadataCtx, metadataCancel := context.WithTimeout(context.Background(), 10*time.Second) + err = couchClient.StoreSyncMetadata(metadataCtx, dbName, newMetadata) + metadataCancel() + if err != nil { + log.Printf(" WARNING: Failed to store sync metadata for %s: %v", mailbox, err) + } else { + fmt.Printf(" Updated sync metadata (last UID: %d)\n", maxUID) + } } else { - fmt.Printf(" Updated sync metadata (last UID: %d)\n", maxUID) + fmt.Printf(" DRY-RUN: Would update sync metadata (last UID: %d, %d messages)\n", maxUID, stored) } } } - fmt.Printf(" Summary: Processed %d messages, stored %d new messages\n", totalMessages, totalStored) + if dryRun { + fmt.Printf(" DRY-RUN Summary: Found %d messages, would store %d messages\n", totalMessages, totalStored) + } else { + fmt.Printf(" Summary: Processed %d messages, stored %d new messages\n", totalMessages, totalStored) + } return nil }