Skip to content

Commit 7d42c11

Browse files
authored
Log rotation and CLI output flags (--color, --quiet, --verbose) (#10)
* feat(log): add age-based retention for per-run log files * feat(cli): add --color=auto|always|never with NO_COLOR support * feat(cli): add --quiet and --verbose with stderr routing for diagnostics * docs(readme): document --color, --quiet, --verbose, and log rotation * test(cli): add resolveVerbosity, color=always, log rotation end-to-end tests * fix(log): parse pruned log timestamps in local timezone
1 parent 256b97d commit 7d42c11

11 files changed

Lines changed: 1110 additions & 64 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ shuttle version Print version
9393
| `--skip <name>` | | Skip a job by name (repeatable, mutually exclusive with `--only`) |
9494
| `--only <name>` | | Run only the named job(s) (repeatable, mutually exclusive with `--skip`) |
9595
| `--remote <name>` | | Target a specific cloud remote (repeatable) |
96+
| `--color <when>` | | Colorize terminal output: `auto` (default), `always`, or `never`. The `NO_COLOR` environment variable always forces color off. |
97+
| `--quiet` | `-q` | Suppress stdout on success; on failure, route summary and log path to stderr. Mutually exclusive with `--verbose`. |
98+
| `--verbose` | `-v` | Print executed commands (`exec: rsync ...` / `exec: rclone ...`) in addition to normal output. Mutually exclusive with `--quiet`. |
99+
100+
### Output streams
101+
102+
Informational output (banners, progress, per-job status, the final summary) goes to **stdout**. Diagnostic output (`[WARN]`, `[ERROR]`) goes to **stderr**, matching rsync and rclone. Scripts redirecting with `shuttle > log.txt` capture only the informational stream; add `2>&1` to also capture diagnostics.
96103

97104
### Exit Codes
98105

@@ -111,6 +118,12 @@ Config lives at `${XDG_CONFIG_HOME:-~/.config}/shuttle/config.toml`. See [`confi
111118

112119
Optional baseline settings applied to all jobs of a given engine. Per-job fields override these.
113120

121+
**`[defaults]`** (cross-cutting)
122+
123+
| Field | Type | Description |
124+
|-------|------|-------------|
125+
| `log_retention_days` | int | Age (in days) after which per-run log files are pruned on startup. Defaults to 30. Set to `0` to disable pruning. Negative values are rejected. |
126+
114127
**`[defaults.rsync]`**
115128

116129
| Field | Type | Description |
@@ -198,6 +211,8 @@ backup_retention_days = 365
198211

199212
Logs are written to `${XDG_STATE_HOME:-~/.local/state}/shuttle/logs/`. Each run creates a timestamped log file. The path is printed at the end of every run.
200213

214+
At startup Shuttle prunes log files older than `log_retention_days` (default 30) so the directory does not grow unbounded under regular cron use. Pruning is best-effort: a failure on any individual file is recorded as a warning and does not block the backup.
215+
201216
## Encrypted Rclone Remotes
202217

203218
If your rclone config is encrypted, Shuttle prompts for the password on interactive terminals. For unattended runs (cron, launchd), set `RCLONE_CONFIG_PASS` in the environment.

cmd/shuttle/main.go

Lines changed: 136 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io"
78
"os"
89
"os/signal"
910
"path/filepath"
1011
"syscall"
12+
"time"
1113

1214
"github.com/spf13/cobra"
1315
"golang.org/x/term"
@@ -30,18 +32,40 @@ func main() {
3032
os.Exit(run())
3133
}
3234

35+
// cliFlags holds the resolved CLI inputs for a single run. Collecting them in
36+
// one struct keeps executeRun's signature below the project's 3-parameter
37+
// threshold and makes it obvious which fields belong to the CLI boundary vs
38+
// the engine's RunOptions.
39+
type cliFlags struct {
40+
RunOpts engine.RunOptions
41+
SkipJobs []string
42+
OnlyJobs []string
43+
SelectedRemotes []string
44+
ColorMode string
45+
Quiet bool
46+
Verbose bool
47+
}
48+
3349
// run is the real entry point, returning an exit code so main stays testable.
3450
// Exit codes: 0 success, 1 partial task failure, 2 config/usage error, 130 signal.
3551
func run() int {
36-
var runOpts engine.RunOptions
37-
var skipJobs, onlyJobs, selectedRemotes []string
52+
var cli cliFlags
3853

3954
rootCmd := &cobra.Command{
4055
Use: "shuttle",
4156
Short: "Automated backup and synchronization tool",
4257
// No subcommand: delegate to executeRun so `shuttle --dry-run` works.
58+
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
59+
if err := validateColorMode(cli.ColorMode); err != nil {
60+
return err
61+
}
62+
if cli.Quiet && cli.Verbose {
63+
return fmt.Errorf("--quiet and --verbose are mutually exclusive")
64+
}
65+
return nil
66+
},
4367
RunE: func(cmd *cobra.Command, args []string) error {
44-
return executeRun(cmd.Context(), skipJobs, onlyJobs, selectedRemotes, runOpts)
68+
return executeRun(cmd.Context(), cli)
4569
},
4670
SilenceUsage: true,
4771
SilenceErrors: true,
@@ -51,7 +75,7 @@ func run() int {
5175
Use: "run",
5276
Short: "Execute sync tasks (default when no subcommand given)",
5377
RunE: func(cmd *cobra.Command, args []string) error {
54-
return executeRun(cmd.Context(), skipJobs, onlyJobs, selectedRemotes, runOpts)
78+
return executeRun(cmd.Context(), cli)
5579
},
5680
SilenceUsage: true,
5781
SilenceErrors: true,
@@ -88,10 +112,13 @@ func run() int {
88112
// Register the same flags on both root and run so both invocation styles
89113
// (`shuttle --dry-run` and `shuttle run --dry-run`) accept them.
90114
for _, cmd := range []*cobra.Command{rootCmd, runCmd} {
91-
cmd.Flags().BoolVarP(&runOpts.DryRun, "dry-run", "n", false, "Preview changes without modifying files")
92-
cmd.Flags().StringArrayVar(&skipJobs, "skip", nil, "Skip a job by name (repeatable; mutually exclusive with --only)")
93-
cmd.Flags().StringArrayVar(&onlyJobs, "only", nil, "Run only named jobs (repeatable; mutually exclusive with --skip)")
94-
cmd.Flags().StringArrayVar(&selectedRemotes, "remote", nil, "Target specific cloud remote by name (repeatable)")
115+
cmd.Flags().BoolVarP(&cli.RunOpts.DryRun, "dry-run", "n", false, "Preview changes without modifying files")
116+
cmd.Flags().StringArrayVar(&cli.SkipJobs, "skip", nil, "Skip a job by name (repeatable; mutually exclusive with --only)")
117+
cmd.Flags().StringArrayVar(&cli.OnlyJobs, "only", nil, "Run only named jobs (repeatable; mutually exclusive with --skip)")
118+
cmd.Flags().StringArrayVar(&cli.SelectedRemotes, "remote", nil, "Target specific cloud remote by name (repeatable)")
119+
cmd.Flags().StringVar(&cli.ColorMode, "color", colorAuto, "Colorize terminal output: auto|always|never")
120+
cmd.Flags().BoolVarP(&cli.Quiet, "quiet", "q", false, "Suppress terminal output on success (mutually exclusive with --verbose)")
121+
cmd.Flags().BoolVarP(&cli.Verbose, "verbose", "v", false, "Show executed commands and extra diagnostics (mutually exclusive with --quiet)")
95122
}
96123

97124
rootCmd.AddCommand(runCmd, versionCmd, validateCmd)
@@ -135,16 +162,71 @@ const (
135162
exitSignal = 130 // Unix convention: 128 + SIGINT
136163
)
137164

165+
// Valid values for the --color flag.
166+
const (
167+
colorAuto = "auto"
168+
colorAlways = "always"
169+
colorNever = "never"
170+
)
171+
172+
// validateColorMode returns an error when mode is not one of the supported
173+
// --color values. Matching is case-sensitive so "AUTO" is rejected, matching
174+
// the behavior of git, ripgrep, and ls.
175+
func validateColorMode(mode string) error {
176+
switch mode {
177+
case colorAuto, colorAlways, colorNever:
178+
return nil
179+
default:
180+
return fmt.Errorf("invalid --color value %q; must be one of %q, %q, %q",
181+
mode, colorAuto, colorAlways, colorNever)
182+
}
183+
}
184+
185+
// resolveVerbosity collapses the two boolean CLI flags into the Logger's
186+
// Verbosity enum. The caller has already ensured quiet and verbose are not
187+
// both set (via PersistentPreRunE).
188+
func resolveVerbosity(quiet, verbose bool) log.Verbosity {
189+
switch {
190+
case quiet:
191+
return log.VerbosityQuiet
192+
case verbose:
193+
return log.VerbosityVerbose
194+
default:
195+
return log.VerbosityNormal
196+
}
197+
}
198+
199+
// resolveColor decides whether ANSI color output should be enabled based on
200+
// the --color mode, whether stdout is a TTY, and whether the NO_COLOR
201+
// environment variable is set. NO_COLOR forces color off regardless of mode
202+
// per https://no-color.org. Kept pure (no env access) so it is trivially
203+
// testable; the caller reads NO_COLOR at the boundary.
204+
func resolveColor(mode string, stdoutIsTTY, noColor bool) bool {
205+
if noColor {
206+
return false
207+
}
208+
switch mode {
209+
case colorAlways:
210+
return true
211+
case colorNever:
212+
return false
213+
default: // colorAuto
214+
return stdoutIsTTY
215+
}
216+
}
217+
138218
// errPartialFailure is the sentinel returned by executeRun when at least one
139219
// sync item failed. The caller maps it to exitPartialFailure.
140220
var errPartialFailure = fmt.Errorf("one or more tasks failed")
141221

142222
// executeRun loads config, sets up the logger, optionally prompts for the
143223
// rclone config password, then runs the full sync pipeline.
144-
func executeRun(ctx context.Context, skip, only, remotes []string, opts engine.RunOptions) error {
145-
opts.SkipJobs = skip
146-
opts.OnlyJobs = only
147-
opts.SelectedRemotes = remotes
224+
func executeRun(ctx context.Context, cli cliFlags) error {
225+
opts := cli.RunOpts
226+
opts.SkipJobs = cli.SkipJobs
227+
opts.OnlyJobs = cli.OnlyJobs
228+
opts.SelectedRemotes = cli.SelectedRemotes
229+
verbosity := resolveVerbosity(cli.Quiet, cli.Verbose)
148230

149231
cfg, err := config.Load()
150232
if err != nil {
@@ -165,8 +247,17 @@ func executeRun(ctx context.Context, skip, only, remotes []string, opts engine.R
165247
}
166248

167249
logDir := logDirectory()
168-
useColor := term.IsTerminal(int(os.Stdout.Fd()))
169-
logger, logPath, err := log.New(logDir, useColor)
250+
stdoutIsTTY := term.IsTerminal(int(os.Stdout.Fd()))
251+
noColor := os.Getenv("NO_COLOR") != ""
252+
useColor := resolveColor(cli.ColorMode, stdoutIsTTY, noColor)
253+
interactive := stdoutIsTTY
254+
255+
// Prune stale logs before opening a new one so the new file doesn't
256+
// count against the retention window. Failures here are operational
257+
// metadata issues, never a reason to block a backup run.
258+
pruneDeleted, pruneWarnings, pruneErr := log.PruneOldLogs(logDir, cfg.ResolvedLogRetentionDays(), time.Now())
259+
260+
logger, logPath, err := log.New(logDir, useColor, verbosity)
170261
if err != nil {
171262
return fmt.Errorf("setting up logging: %w", err)
172263
}
@@ -176,18 +267,46 @@ func executeRun(ctx context.Context, skip, only, remotes []string, opts engine.R
176267
if opts.DryRun {
177268
logger.Warn("DRY RUN: no files will be modified.")
178269
}
270+
if pruneErr != nil {
271+
logger.Warn(fmt.Sprintf("log rotation skipped: %v", pruneErr))
272+
}
273+
for _, w := range pruneWarnings {
274+
logger.Warn("log rotation: " + w)
275+
}
276+
if pruneDeleted > 0 {
277+
logger.Info(fmt.Sprintf("pruned %d old log file(s)", pruneDeleted))
278+
}
179279

180280
promptForPassword(logger)
181281

182-
pw := engine.NewProgressWriter(os.Stdout, useColor, useColor)
282+
// In quiet mode, the per-job spinner and status lines are suppressed so
283+
// the terminal stays silent unless something fails.
284+
progressOut := io.Writer(os.Stdout)
285+
progressInteractive := interactive
286+
if verbosity == log.VerbosityQuiet {
287+
progressOut = io.Discard
288+
progressInteractive = false
289+
}
290+
291+
pw := engine.NewProgressWriter(progressOut, progressInteractive, useColor)
183292
runner := engine.NewRunner(cfg, configPath, logger, pw, opts.DryRun, logPath)
184293
summary, err := runner.Run(ctx, opts)
185294
if err != nil {
186295
return err
187296
}
188297

189-
engine.RenderSummary(os.Stdout, summary, useColor)
190-
fmt.Printf("\nLog: %s\n", logPath)
298+
if verbosity == log.VerbosityQuiet {
299+
// Quiet-on-success: print nothing. On failure, route the summary and
300+
// log pointer to stderr so cron-style "only notify on error" wrappers
301+
// still have context.
302+
if summary.HasErrors() {
303+
engine.RenderSummary(os.Stderr, summary, useColor)
304+
fmt.Fprintf(os.Stderr, "\nLog: %s\n", logPath)
305+
}
306+
} else {
307+
engine.RenderSummary(os.Stdout, summary, useColor)
308+
fmt.Printf("\nLog: %s\n", logPath)
309+
}
191310

192311
if summary.HasErrors() {
193312
return errPartialFailure

0 commit comments

Comments
 (0)