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

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 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2025-08-03 18:21:27 +02:00
commit 61ba952155
2 changed files with 100 additions and 57 deletions

View file

@ -86,6 +86,7 @@ func (ms *MailSource) IsArchiveMode() bool {
type CommandLineArgs struct { type CommandLineArgs struct {
ConfigPath string ConfigPath string
MaxMessages int MaxMessages int
DryRun bool
} }
// ParseCommandLine parses command line arguments using GNU-style options // ParseCommandLine parses command line arguments using GNU-style options
@ -95,6 +96,7 @@ func ParseCommandLine() *CommandLineArgs {
// Define long options with -- and short options with - // Define long options with -- and short options with -
pflag.StringVarP(&args.ConfigPath, "config", "c", "", "Path to configuration file") 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.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 // Add utility options
pflag.BoolP("help", "h", false, "Show help message") pflag.BoolP("help", "h", false, "Show help message")
@ -146,7 +148,7 @@ _%s_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
@ -217,5 +219,8 @@ func LoadConfigWithDiscovery(args *CommandLineArgs) (*Config, error) {
} else { } else {
fmt.Printf("Maximum messages per mailbox: unlimited\n") 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) return LoadConfig(configPath)
} }

View file

@ -19,11 +19,15 @@ func main() {
log.Fatalf("Failed to load configuration: %v", err) log.Fatalf("Failed to load configuration: %v", err)
} }
// Initialize CouchDB client // Initialize CouchDB client (skip in dry-run mode)
couchClient, err := couch.NewClient(&cfg.CouchDb) var couchClient *couch.Client
if !args.DryRun {
var err error
couchClient, err = couch.NewClient(&cfg.CouchDb)
if err != nil { if err != nil {
log.Fatalf("Failed to create CouchDB client: %v", err) log.Fatalf("Failed to create CouchDB client: %v", err)
} }
}
fmt.Printf("Found %d mail source(s) to process.\n", len(cfg.MailSources)) fmt.Printf("Found %d mail source(s) to process.\n", len(cfg.MailSources))
for _, source := range cfg.MailSources { for _, source := range cfg.MailSources {
@ -34,7 +38,8 @@ func main() {
// Generate per-account database name // Generate per-account database name
dbName := couch.GenerateAccountDBName(source.Name, source.User) dbName := couch.GenerateAccountDBName(source.Name, source.User)
// Ensure the account-specific database exists // Ensure the account-specific database exists (skip in dry-run mode)
if !args.DryRun {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err = couchClient.EnsureDB(ctx, dbName) err = couchClient.EnsureDB(ctx, dbName)
cancel() cancel()
@ -45,10 +50,13 @@ func main() {
} else { } else {
fmt.Printf("CouchDB database '%s' is ready for account: %s\n", dbName, source.Name) fmt.Printf("CouchDB database '%s' is ready for account: %s\n", dbName, source.Name)
} }
} else {
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) fmt.Printf(" - Processing source: %s\n", source.Name)
if source.Protocol == "imap" { if source.Protocol == "imap" {
err := processImapSource(&source, couchClient, dbName, args.MaxMessages) err := processImapSource(&source, couchClient, dbName, args.MaxMessages, args.DryRun)
if err != nil { if err != nil {
log.Printf(" ERROR: Failed to process IMAP source %s: %v", source.Name, err) 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) fmt.Printf(" Connecting to IMAP server: %s:%d\n", source.Host, source.Port)
imapClient, err := mail.NewImapClient(source) imapClient, err := mail.NewImapClient(source)
if err != nil { if err != nil {
@ -92,14 +100,18 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN
for _, mailbox := range mailboxes { for _, mailbox := range mailboxes {
fmt.Printf(" Processing mailbox: %s (mode: %s)\n", mailbox, source.Mode) fmt.Printf(" Processing mailbox: %s (mode: %s)\n", mailbox, source.Mode)
// Get sync metadata to determine incremental sync date // 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) syncCtx, syncCancel := context.WithTimeout(context.Background(), 10*time.Second)
syncMetadata, err := couchClient.GetSyncMetadata(syncCtx, dbName, mailbox) var err error
syncMetadata, err = couchClient.GetSyncMetadata(syncCtx, dbName, mailbox)
syncCancel() syncCancel()
if err != nil { if err != nil {
log.Printf(" ERROR: Failed to get sync metadata for %s: %v", mailbox, err) log.Printf(" ERROR: Failed to get sync metadata for %s: %v", mailbox, err)
continue continue
} }
}
// Determine the since date for incremental sync // Determine the since date for incremental sync
var sinceDate *time.Time var sinceDate *time.Time
@ -113,10 +125,14 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN
sinceDate = configSinceDate sinceDate = configSinceDate
if sinceDate != nil { if sinceDate != nil {
fmt.Printf(" First sync since: %s (from config)\n", sinceDate.Format("2006-01-02")) fmt.Printf(" First sync since: %s (from config)\n", sinceDate.Format("2006-01-02"))
} else {
if dryRun {
fmt.Printf(" DRY-RUN: Would perform first full sync (no date filter)\n")
} else { } else {
fmt.Printf(" First full sync (no date filter)\n") fmt.Printf(" First full sync (no date filter)\n")
} }
} }
}
// Retrieve messages from the mailbox // Retrieve messages from the mailbox
messages, currentUIDs, err := imapClient.GetMessages(mailbox, sinceDate, maxMessages, &source.MessageFilter) messages, currentUIDs, err := imapClient.GetMessages(mailbox, sinceDate, maxMessages, &source.MessageFilter)
@ -125,7 +141,8 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN
continue continue
} }
// Perform sync/archive logic // Perform sync/archive logic (skip in dry-run mode)
if !dryRun {
mailboxSyncCtx, mailboxSyncCancel := context.WithTimeout(context.Background(), 30*time.Second) mailboxSyncCtx, mailboxSyncCancel := context.WithTimeout(context.Background(), 30*time.Second)
err = couchClient.SyncMailbox(mailboxSyncCtx, dbName, mailbox, currentUIDs, source.IsSyncMode()) err = couchClient.SyncMailbox(mailboxSyncCtx, dbName, mailbox, currentUIDs, source.IsSyncMode())
mailboxSyncCancel() mailboxSyncCancel()
@ -133,6 +150,10 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN
log.Printf(" ERROR: Failed to sync mailbox %s: %v", mailbox, err) log.Printf(" ERROR: Failed to sync mailbox %s: %v", mailbox, err)
continue 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 { if len(messages) == 0 {
fmt.Printf(" No new messages found in %s\n", mailbox) fmt.Printf(" No new messages found in %s\n", mailbox)
@ -149,9 +170,10 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN
docs = append(docs, doc) docs = append(docs, doc)
} }
// Store messages in CouchDB with attachments // Store messages in CouchDB with attachments (skip in dry-run mode)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
stored := 0 stored := 0
if !dryRun {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
for i, doc := range docs { for i, doc := range docs {
err := couchClient.StoreMessage(ctx, dbName, doc, messages[i]) err := couchClient.StoreMessage(ctx, dbName, doc, messages[i])
if err != nil { if err != nil {
@ -161,11 +183,19 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN
} }
} }
cancel() cancel()
fmt.Printf(" Stored %d/%d messages from %s\n", stored, len(messages), mailbox) 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)
}
}
totalStored += stored totalStored += stored
// Update sync metadata after successful processing // Update sync metadata after successful processing (skip in dry-run mode)
if len(messages) > 0 { if len(messages) > 0 {
// Find the highest UID processed // Find the highest UID processed
var maxUID uint32 var maxUID uint32
@ -175,6 +205,7 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN
} }
} }
if !dryRun {
// Create/update sync metadata // Create/update sync metadata
newMetadata := &couch.SyncMetadata{ newMetadata := &couch.SyncMetadata{
Mailbox: mailbox, Mailbox: mailbox,
@ -192,9 +223,16 @@ func processImapSource(source *config.MailSource, couchClient *couch.Client, dbN
} else { } else {
fmt.Printf(" Updated sync metadata (last UID: %d)\n", maxUID) fmt.Printf(" Updated sync metadata (last UID: %d)\n", maxUID)
} }
} else {
fmt.Printf(" DRY-RUN: Would update sync metadata (last UID: %d, %d messages)\n", maxUID, stored)
}
} }
} }
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) fmt.Printf(" Summary: Processed %d messages, stored %d new messages\n", totalMessages, totalStored)
}
return nil return nil
} }