// Package daemon coordinates periodic sync cycles and change hooks. package daemon import ( "context" "fmt" "log/slog" "time" "s3watch/internal/syncer" ) // Config controls daemon scheduling behavior. type Config struct { Interval time.Duration Once bool } // Syncer synchronizes remote state into local state. type Syncer interface { Sync(context.Context) (syncer.Result, error) } // HookRunner runs after a sync cycle applies changes. type HookRunner interface { Run(context.Context, syncer.Result) error } // Run executes sync cycles until the context is canceled. func Run(ctx context.Context, cfg Config, syncService Syncer, runner HookRunner, logger *slog.Logger) error { if cfg.Interval <= 0 { return fmt.Errorf("interval must be greater than zero") } if syncService == nil { return fmt.Errorf("syncer is required") } if logger == nil { logger = slog.Default() } for { if err := runCycle(ctx, syncService, runner, logger, cfg.Once); err != nil { if cfg.Once { return err } logger.Error("sync cycle failed", "error", err) } if cfg.Once { return nil } timer := time.NewTimer(cfg.Interval) select { case <-ctx.Done(): if !timer.Stop() { select { case <-timer.C: default: } } return nil case <-timer.C: } } } func runCycle(ctx context.Context, syncService Syncer, runner HookRunner, logger *slog.Logger, strict bool) error { result, err := syncService.Sync(ctx) if err != nil { return fmt.Errorf("syncing: %w", err) } logger.Info("sync cycle completed", "downloaded", result.Downloaded, "uploaded", result.Uploaded, "updated", result.Updated, "deleted", result.Deleted, "unchanged", result.Unchanged, ) if !result.Changed() || runner == nil { return nil } if err := runner.Run(ctx, result); err != nil { if strict { return fmt.Errorf("running hook: %w", err) } logger.Error("hook failed", "error", err) } return nil }