diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8e90817..627cec6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -22,6 +22,7 @@ builds: - -s -w - -X 'sneak.berlin/go/vaultik/internal/globals.Version={{ .Version }}' - -X 'sneak.berlin/go/vaultik/internal/globals.Commit={{ .Commit }}' + - -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate={{ .CommitDate }}' archives: - id: default diff --git a/Dockerfile b/Dockerfile index 7ca5651..fc50065 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,8 +41,8 @@ COPY . . # Run tests RUN make test -# Build with CGO enabled (required for mattn/go-sqlite3) -RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(git rev-parse HEAD 2>/dev/null || echo unknown)'" -o /vaultik ./cmd/vaultik +# Build (pure Go, no CGO required since we use modernc.org/sqlite) +RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(git rev-parse HEAD 2>/dev/null || echo unknown)' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(git show -s --format=%cI HEAD 2>/dev/null || echo unknown)'" -o /vaultik ./cmd/vaultik # Runtime stage # alpine:3.21, 2026-02-25 diff --git a/Makefile b/Makefile index 3b639c7..796e281 100644 --- a/Makefile +++ b/Makefile @@ -5,10 +5,12 @@ VERSION := 1.0.0-rc.1 # Build variables GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") +GIT_COMMIT_DATE := $(shell git show -s --format=%cI HEAD 2>/dev/null || echo "unknown") # Linker flags LDFLAGS := -X 'sneak.berlin/go/vaultik/internal/globals.Version=$(VERSION)' \ - -X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(GIT_REVISION)' + -X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(GIT_REVISION)' \ + -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(GIT_COMMIT_DATE)' # Default target all: vaultik diff --git a/README.md b/README.md index 47ec901..7877bf9 100644 --- a/README.md +++ b/README.md @@ -389,6 +389,65 @@ Items for future releases: --- +## output style + +All user-facing output goes through helpers in `internal/ui` and conforms +to a uniform style. Color is enabled when stdout is a TTY and the +`NO_COLOR` environment variable is unset (https://no-color.org/). + +Message classes: + +| Class | Marker | Alignment | Use for | +|-------|--------|-----------|---------| +| Banner | none | column 0 | The startup line printed once per invocation | +| Begin | `》` (white) | column 0 | An operation is about to start (present-continuous verb) | +| Complete | `》` (green) | column 0 | An operation just finished (past-tense verb) | +| Info | `》` (white) | column 0 | Neutral status update | +| Notice | `》` (cyan) | column 0 | Important note that is not a warning | +| Warning | `Warning:` (orange/yellow) | column 0 | Recoverable problem | +| Error | `ERROR:` (red) | column 0 | Operation aborted | +| Progress | ` 》` (white) | column 2 | Heartbeat or per-item status during a long-running operation | + +Conventions: + +* Messages are complete English sentences ending with a period. +* Fully qualify terms — say "backup destination store" instead of + "storage", "snapshot source files enumeration" instead of "scan", + "local index database" instead of "database". +* Every operation that emits a Complete also emits a corresponding + Begin. Operations that print only a Begin (because completion is + obvious from a later Begin) should be rare and intentional. +* Use natural verb tense to signal state: "Uploading" for Begin, + "Uploaded" for Complete. Never write the words "begin" or "complete" + in the body — the marker color already conveys that. +* All elapsed and remaining-time fields are explicitly scoped to their + subject: write "blob upload elapsed 30s, blob upload estimated remaining + time (14s), finish at 2026-06-17T03:15:00Z", never just "elapsed 30s, + ETA 14s". +* "ETA" means an absolute clock time (when the operation will finish), + not a remaining-duration. Use `ui.Time()` for the former and + `ui.Duration()` for the latter, and label both. + +Value colorizers in `internal/ui` colorize specific value types +consistently. Compose messages from these helpers rather than embedding +ANSI escapes inline: + +| Helper | Color | Use for | +|--------|-------|---------| +| `Hex` | cyan | Blob hashes, chunk hashes (truncated to 12 chars + `...`) | +| `Snapshot` | bold cyan | Snapshot IDs (untruncated) | +| `Path` | blue | Filesystem paths | +| `Size` | magenta | Byte counts (human-readable) | +| `Speed` | magenta | Bytes-per-second rates | +| `Duration` | yellow | Elapsed or remaining time | +| `Time` | yellow | Absolute clock times | +| `Count` | magenta | Integer counts with thousands separators | +| `Percent` | magenta | Percentages | + +When `NO_COLOR` is set or output is not a TTY, all helpers return plain +text and the marker prefixes (`》`, `Warning:`, `ERROR:`) emit without +ANSI escapes. + ## requirements * Go 1.26 or later diff --git a/internal/cli/app.go b/internal/cli/app.go index a595f6f..4ec7063 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "io" "os" "os/signal" "path/filepath" + "strings" "syscall" "time" @@ -19,6 +21,7 @@ import ( "sneak.berlin/go/vaultik/internal/pidlock" "sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/storage" + "sneak.berlin/go/vaultik/internal/ui" "sneak.berlin/go/vaultik/internal/vaultik" ) @@ -32,11 +35,21 @@ type AppOptions struct { Invokes []fx.Option } -// setupGlobals sets up the globals with application startup time -func setupGlobals(lc fx.Lifecycle, g *globals.Globals) { +// setupGlobals records the startup time and prints the startup banner. +// In --cron mode the banner is suppressed (LogOptions.Cron == true). +func setupGlobals(lc fx.Lifecycle, g *globals.Globals, v *vaultik.Vaultik, opts log.LogOptions) { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { g.StartTime = time.Now().UTC() + if opts.Cron || opts.Quiet { + // Replace UI writer with a discarding one so all + // user-facing output is suppressed. + v.UI = ui.NewWithColor(io.Discard, false) + } else { + v.UI.Banner("%s %s (commit %s, %s) invoked at %s by %s", + g.Appname, g.Version, g.ShortCommit(), g.CommitDate, + g.StartTime.Format(time.RFC3339), globals.Author) + } return nil }, }) @@ -68,6 +81,24 @@ func NewApp(opts AppOptions) *fx.App { return fx.New(allOptions...) } +// cleanStartupError strips fx's dependency-injection call-chain noise from +// startup errors. fx wraps the underlying error with messages like +// +// could not build arguments for function "X" (file:line): failed to build T: +// could not build arguments for function "Y" (file:line): failed to build U: +// received non-nil error from function "Z" (file:line): +// +// Users care about the real error, not the DI plumbing. We strip everything +// up through the last "): " (which is always the close-paren of an fx +// function-location annotation followed by the wrapped error). +func cleanStartupError(err error) error { + msg := err.Error() + if idx := strings.LastIndex(msg, "): "); idx >= 0 { + msg = msg[idx+3:] + } + return errors.New(msg) +} + // RunApp starts and stops the fx application within the given context. // It handles graceful shutdown on interrupt signals (SIGINT, SIGTERM) and // ensures the application stops cleanly. The function blocks until the @@ -83,7 +114,7 @@ func RunApp(ctx context.Context, app *fx.App) error { // Start the app if err := app.Start(ctx); err != nil { - return fmt.Errorf("failed to start app: %w", err) + return cleanStartupError(err) } // Handle shutdown diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go new file mode 100644 index 0000000..1d90476 --- /dev/null +++ b/internal/cli/app_test.go @@ -0,0 +1,39 @@ +package cli + +import ( + "errors" + "testing" +) + +func TestCleanStartupError(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "real fx error chain", + in: `could not build arguments for function "sneak.berlin/go/vaultik/internal/cli".newSnapshotCreateCommand.func1.1 (/Users/user/dev/vaultik/internal/cli/snapshot.go:71): failed to build *vaultik.Vaultik: could not build arguments for function "sneak.berlin/go/vaultik/internal/vaultik".New (/Users/user/dev/vaultik/internal/vaultik/vaultik.go:59): failed to build storage.Storer: received non-nil error from function "sneak.berlin/go/vaultik/internal/storage".NewStorer (/Users/user/dev/vaultik/internal/storage/module.go:23): creating base path: mkdir /Volumes/BACKUPS: permission denied`, + want: `creating base path: mkdir /Volumes/BACKUPS: permission denied`, + }, + { + name: "no fx wrapping", + in: "plain error", + want: "plain error", + }, + { + name: "single fx wrapping", + in: `received non-nil error from function "foo" (file.go:1): underlying problem`, + want: "underlying problem", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cleanStartupError(errors.New(tt.in)).Error() + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index 78f12f3..e34cd4c 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -3,7 +3,6 @@ package cli import ( "context" "fmt" - "io" "os" "github.com/spf13/cobra" @@ -73,9 +72,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, OnStart: func(ctx context.Context) error { // Start the snapshot creation in a goroutine go func() { - if opts.Cron { - v.Stdout = io.Discard - } + // --cron suppression is wired through v.UI by setupGlobals. if err := v.CreateSnapshot(opts); err != nil { if err != context.Canceled { log.Error("Snapshot creation failed", "error", err) diff --git a/internal/globals/globals.go b/internal/globals/globals.go index 2b49877..f91c58c 100644 --- a/internal/globals/globals.go +++ b/internal/globals/globals.go @@ -13,19 +13,36 @@ var Version string = "dev" // Commit is the git commit hash, populated from main(). var Commit string = "unknown" +// CommitDate is the ISO-8601 date of the commit, populated from main(). +var CommitDate string = "unknown" + +// Author identifies the upstream author of vaultik. +const Author = "Jeffrey Paul " + // Globals contains application-wide configuration and metadata. type Globals struct { - Appname string - Version string - Commit string - StartTime time.Time + Appname string + Version string + Commit string + CommitDate string + StartTime time.Time } // New creates and returns a new Globals instance initialized with the package-level variables. func New() (*Globals, error) { return &Globals{ - Appname: Appname, - Version: Version, - Commit: Commit, + Appname: Appname, + Version: Version, + Commit: Commit, + CommitDate: CommitDate, }, nil } + +// ShortCommit returns the first 12 chars of the commit hash, or the +// whole string if it's shorter (e.g. "unknown"). +func (g *Globals) ShortCommit() string { + if len(g.Commit) > 12 { + return g.Commit[:12] + } + return g.Commit +} diff --git a/internal/snapshot/module.go b/internal/snapshot/module.go index 44cd6a1..eca0b1d 100644 --- a/internal/snapshot/module.go +++ b/internal/snapshot/module.go @@ -1,19 +1,18 @@ package snapshot import ( - "io" - "github.com/spf13/afero" "go.uber.org/fx" "sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/storage" + "sneak.berlin/go/vaultik/internal/ui" ) // ScannerParams holds parameters for scanner creation type ScannerParams struct { EnableProgress bool - Output io.Writer // Where one-off scanner messages go; nil disables them + UI *ui.Writer // Where user-facing scanner messages go; nil = discard Fs afero.Fs Exclude []string // Exclude patterns (combined global + snapshot-specific) SkipErrors bool // Skip file read errors (log loudly but continue) @@ -49,7 +48,7 @@ func provideScannerFactory(cfg *config.Config, repos *database.Repositories, sto CompressionLevel: cfg.CompressionLevel, AgeRecipients: cfg.AgeRecipients, EnableProgress: params.EnableProgress, - Output: params.Output, + UI: params.UI, Exclude: excludes, SkipErrors: params.SkipErrors, }) diff --git a/internal/snapshot/scanner.go b/internal/snapshot/scanner.go index 61f7fee..bdb84df 100644 --- a/internal/snapshot/scanner.go +++ b/internal/snapshot/scanner.go @@ -22,6 +22,7 @@ import ( "sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/types" + "sneak.berlin/go/vaultik/internal/ui" ) // FileToProcess holds information about a file that needs processing @@ -60,8 +61,8 @@ type Scanner struct { exclude []string // Glob patterns for files/directories to exclude compiledExclude []compiledPattern // Compiled glob patterns progress *ProgressReporter - skipErrors bool // Skip file read errors (log loudly but continue) - output io.Writer // User-facing output (os.Stdout or io.Discard in cron mode) + skipErrors bool // Skip file read errors (log loudly but continue) + ui *ui.Writer // User-facing output; never nil (defaults to a discarding writer) // In-memory cache of known chunk hashes for fast existence checks knownChunks map[string]struct{} @@ -92,11 +93,11 @@ type ScannerConfig struct { Storage storage.Storer MaxBlobSize int64 CompressionLevel int - AgeRecipients []string // Optional, empty means no encryption - EnableProgress bool // Enable the live progress reporter (ETAs, throughput) - Output io.Writer // Where one-off scanner messages go; nil disables them - Exclude []string // Glob patterns for files/directories to exclude - SkipErrors bool // Skip file read errors (log loudly but continue) + AgeRecipients []string // Optional, empty means no encryption + EnableProgress bool // Enable the live progress reporter (ETAs, throughput) + UI *ui.Writer // Where user-facing scanner messages go; nil = discard + Exclude []string // Glob patterns for files/directories to exclude + SkipErrors bool // Skip file read errors (log loudly but continue) } // ScanResult contains the results of a scan operation @@ -143,9 +144,9 @@ func NewScanner(cfg ScannerConfig) *Scanner { // Compile exclude patterns compiledExclude := compileExcludePatterns(cfg.Exclude) - output := cfg.Output - if output == nil { - output = io.Discard + uiw := cfg.UI + if uiw == nil { + uiw = ui.NewWithColor(io.Discard, false) } return &Scanner{ @@ -161,7 +162,7 @@ func NewScanner(cfg ScannerConfig) *Scanner { compiledExclude: compiledExclude, progress: progress, skipErrors: cfg.SkipErrors, - output: output, + ui: uiw, pendingChunkHashes: make(map[string]struct{}), } } @@ -212,7 +213,7 @@ func (s *Scanner) Scan(ctx context.Context, path string, snapshotID string) (*Sc // Phase 1c: Associate unchanged files with this snapshot (no new records needed) if len(scanResult.UnchangedFileIDs) > 0 { - _, _ = fmt.Fprintf(s.output, "Associating %s unchanged files with snapshot...\n", formatNumber(len(scanResult.UnchangedFileIDs))) + s.ui.Begin("Associating %s unchanged files with the snapshot.", s.ui.Count(len(scanResult.UnchangedFileIDs))) if err := s.batchAddFilesToSnapshot(ctx, scanResult.UnchangedFileIDs); err != nil { return nil, fmt.Errorf("associating unchanged files: %w", err) } @@ -223,13 +224,13 @@ func (s *Scanner) Scan(ctx context.Context, path string, snapshotID string) (*Sc // Phase 2: Process files and create chunks if len(filesToProcess) > 0 { - _, _ = fmt.Fprintf(s.output, "Processing %s files...\n", formatNumber(len(filesToProcess))) + s.ui.Begin("Processing %s snapshot source files (chunking, compressing, encrypting, uploading).", s.ui.Count(len(filesToProcess))) log.Info("Phase 2/3: Creating snapshot (chunking, compressing, encrypting, and uploading blobs)") if err := s.processPhase(ctx, filesToProcess, result); err != nil { return nil, fmt.Errorf("process phase failed: %w", err) } } else { - _, _ = fmt.Fprintf(s.output, "No files need processing. Creating metadata-only snapshot.\n") + s.ui.Info("Snapshot file processing skipped: no changed files (creating metadata-only snapshot).") log.Info("Phase 2/3: Skipping (no files need processing, metadata-only snapshot)") } @@ -242,18 +243,18 @@ func (s *Scanner) Scan(ctx context.Context, path string, snapshotID string) (*Sc // loadDatabaseState loads known files and chunks from the database into memory for fast lookup // This avoids per-file and per-chunk database queries during the scan and process phases func (s *Scanner) loadDatabaseState(ctx context.Context, path string) (map[string]*database.File, error) { - _, _ = fmt.Fprintln(s.output, "Loading known files from database...") + s.ui.Begin("Loading known files from local index database.") knownFiles, err := s.loadKnownFiles(ctx, path) if err != nil { return nil, fmt.Errorf("loading known files: %w", err) } - _, _ = fmt.Fprintf(s.output, "Loaded %s known files from database\n", formatNumber(len(knownFiles))) + s.ui.Complete("Loaded %s known files from local index database.", s.ui.Count(len(knownFiles))) - _, _ = fmt.Fprintln(s.output, "Loading known chunks from database...") + s.ui.Begin("Loading known chunks from local index database.") if err := s.loadKnownChunks(ctx); err != nil { return nil, fmt.Errorf("loading known chunks: %w", err) } - _, _ = fmt.Fprintf(s.output, "Loaded %s known chunks from database\n", formatNumber(len(s.knownChunks))) + s.ui.Complete("Loaded %s known chunks from local index database.", s.ui.Count(len(s.knownChunks))) return knownFiles, nil } @@ -277,17 +278,17 @@ func (s *Scanner) summarizeScanPhase(result *ScanResult, filesToProcess []*FileT "files_skipped", result.FilesSkipped, "bytes_skipped", humanize.Bytes(uint64(result.BytesSkipped))) - _, _ = fmt.Fprintf(s.output, "Scan complete: %s examined (%s), %s to process (%s)", - formatNumber(result.FilesScanned), - humanize.Bytes(uint64(totalSizeToProcess+result.BytesSkipped)), - formatNumber(len(filesToProcess)), - humanize.Bytes(uint64(totalSizeToProcess))) + msg := fmt.Sprintf("Enumerated %s snapshot source files (%s total), %s to process (%s)", + s.ui.Count(result.FilesScanned), + s.ui.Size(totalSizeToProcess+result.BytesSkipped), + s.ui.Count(len(filesToProcess)), + s.ui.Size(totalSizeToProcess)) if result.FilesDeleted > 0 { - _, _ = fmt.Fprintf(s.output, ", %s deleted (%s)", - formatNumber(result.FilesDeleted), - humanize.Bytes(uint64(result.BytesDeleted))) + msg += fmt.Sprintf(", %s deleted (%s)", + s.ui.Count(result.FilesDeleted), + s.ui.Size(result.BytesDeleted)) } - _, _ = fmt.Fprintln(s.output) + s.ui.Complete("%s.", msg) } // finalizeScanResult populates final blob statistics in the scan result @@ -628,8 +629,8 @@ func (s *Scanner) scanPhase(ctx context.Context, path string, result *ScanResult err := afero.Walk(s.fs, path, func(filePath string, info os.FileInfo, err error) error { if err != nil { if s.skipErrors { - log.Error("ERROR: Failed to access file (skipping due to --skip-errors)", "path", filePath, "error", err) - _, _ = fmt.Fprintf(s.output, "ERROR: Failed to access %s: %v (skipping)\n", filePath, err) + log.Error("Failed to access file (skipping due to --skip-errors)", "path", filePath, "error", err) + s.ui.Error("Failed to access %s: %v. Skipping (--skip-errors).", s.ui.Path(filePath), err) return nil // Continue scanning } log.Debug("Error accessing filesystem entry", "path", filePath, "error", err) @@ -775,23 +776,29 @@ func (s *Scanner) printScanProgressLine(filesScanned int64, changedCount int, es if rate > 0 && remaining > 0 { eta = time.Duration(float64(remaining)/rate) * time.Second } - _, _ = fmt.Fprintf(s.output, "Scan: %s files (~%.0f%%), %s changed/new, %.0f files/sec, %s elapsed", - formatNumber(int(filesScanned)), - pct, - formatNumber(changedCount), - rate, - elapsed.Round(time.Second)) if eta > 0 { - _, _ = fmt.Fprintf(s.output, ", ETA %s", eta.Round(time.Second)) + s.ui.Progress("Snapshot source files enumeration: %s files (~%s), %s changed or new, %.0f files/sec, enumeration elapsed %s, enumeration estimated remaining time (%s), finish at %s.", + s.ui.Count(int(filesScanned)), + s.ui.Percent(pct), + s.ui.Count(changedCount), + rate, + s.ui.Duration(elapsed), + s.ui.Duration(eta), + s.ui.Time(time.Now().Add(eta))) + } else { + s.ui.Progress("Snapshot source files enumeration: %s files (~%s), %s changed or new, %.0f files/sec, enumeration elapsed %s.", + s.ui.Count(int(filesScanned)), + s.ui.Percent(pct), + s.ui.Count(changedCount), + rate, + s.ui.Duration(elapsed)) } - _, _ = fmt.Fprintln(s.output) } else { - // First backup - no estimate available - _, _ = fmt.Fprintf(s.output, "Scan: %s files, %s changed/new, %.0f files/sec, %s elapsed\n", - formatNumber(int(filesScanned)), - formatNumber(changedCount), + s.ui.Progress("Snapshot source files enumeration: %s files seen, %s changed or new, %.0f files/sec, enumeration elapsed %s.", + s.ui.Count(int(filesScanned)), + s.ui.Count(changedCount), rate, - elapsed.Round(time.Second)) + s.ui.Duration(elapsed)) } } @@ -957,16 +964,16 @@ func (s *Scanner) batchAddFilesToSnapshot(ctx context.Context, fileIDs []types.F elapsed := time.Since(startTime) rate := float64(end) / elapsed.Seconds() pct := float64(end) / float64(len(fileIDs)) * 100 - _, _ = fmt.Fprintf(s.output, "Associating files: %s/%s (%.1f%%), %.0f files/sec\n", - formatNumber(end), formatNumber(len(fileIDs)), pct, rate) + s.ui.Progress("Snapshot unchanged-file association: %s/%s (%s), %.0f files/sec.", + s.ui.Count(end), s.ui.Count(len(fileIDs)), s.ui.Percent(pct), rate) lastStatusTime = time.Now() } } elapsed := time.Since(startTime) rate := float64(len(fileIDs)) / elapsed.Seconds() - _, _ = fmt.Fprintf(s.output, "Associated %s unchanged files in %s (%.0f files/sec)\n", - formatNumber(len(fileIDs)), elapsed.Round(time.Second), rate) + s.ui.Complete("Associated %s unchanged files with the snapshot in %s (%.0f files/sec).", + s.ui.Count(len(fileIDs)), s.ui.Duration(elapsed), rate) return nil } @@ -1034,8 +1041,8 @@ func (s *Scanner) processFileWithErrorHandling(ctx context.Context, fileToProces } // Skip file read errors if --skip-errors is enabled if s.skipErrors { - log.Error("ERROR: Failed to process file (skipping due to --skip-errors)", "path", fileToProcess.Path, "error", err) - _, _ = fmt.Fprintf(s.output, "ERROR: Failed to process %s: %v (skipping)\n", fileToProcess.Path, err) + log.Error("Failed to process file (skipping due to --skip-errors)", "path", fileToProcess.Path, "error", err) + s.ui.Error("Failed to process %s: %v. Skipping (--skip-errors).", s.ui.Path(fileToProcess.Path), err) result.FilesSkipped++ return true, nil } @@ -1059,20 +1066,29 @@ func (s *Scanner) printProcessingProgress(filesProcessed, totalFiles int, bytesP eta = time.Duration(float64(remainingBytes)/byteRate) * time.Second } - // Format: Progress [5.7k/610k] 6.7 GB/44 GB (15.4%), 106MB/sec, 500 files/sec, running for 1m30s, ETA: 5m49s - _, _ = fmt.Fprintf(s.output, "Progress [%s/%s] %s/%s (%.1f%%), %s/sec, %.0f files/sec, running for %s", - formatCompact(filesProcessed), - formatCompact(totalFiles), - humanize.Bytes(uint64(bytesProcessed)), - humanize.Bytes(uint64(totalBytes)), - pct, - humanize.Bytes(uint64(byteRate)), - fileRate, - elapsed.Round(time.Second)) if eta > 0 { - _, _ = fmt.Fprintf(s.output, ", ETA: %s", eta.Round(time.Second)) + s.ui.Progress("Snapshot file processing: %s/%s files (%s), %s/%s, %s, %.0f files/sec, processing elapsed %s, processing estimated remaining time (%s), finish at %s.", + s.ui.Count(filesProcessed), + s.ui.Count(totalFiles), + s.ui.Percent(pct), + s.ui.Size(bytesProcessed), + s.ui.Size(totalBytes), + s.ui.Speed(byteRate), + fileRate, + s.ui.Duration(elapsed), + s.ui.Duration(eta), + s.ui.Time(time.Now().Add(eta))) + } else { + s.ui.Progress("Snapshot file processing: %s/%s files (%s), %s/%s, %s, %.0f files/sec, processing elapsed %s.", + s.ui.Count(filesProcessed), + s.ui.Count(totalFiles), + s.ui.Percent(pct), + s.ui.Size(bytesProcessed), + s.ui.Size(totalBytes), + s.ui.Speed(byteRate), + fileRate, + s.ui.Duration(elapsed)) } - _, _ = fmt.Fprintln(s.output) } // finalizeProcessPhase flushes the packer, writes remaining pending files to the database, @@ -1164,14 +1180,13 @@ func (s *Scanner) uploadBlobIfNeeded(ctx context.Context, blobPath string, blobW if _, err := s.storage.Stat(ctx, blobPath); err == nil { log.Info("Blob already exists in storage, skipping upload", "hash", finishedBlob.Hash, "size", humanize.Bytes(uint64(finishedBlob.Compressed))) - _, _ = fmt.Fprintf(s.output, "Blob exists: %s (%s, skipped upload)\n", - finishedBlob.Hash[:12]+"...", humanize.Bytes(uint64(finishedBlob.Compressed))) + s.ui.Info("Blob %s (%s) already exists in backup destination store. Skipping upload.", + s.ui.Hex(finishedBlob.Hash), s.ui.Size(finishedBlob.Compressed)) return true, nil } - _, _ = fmt.Fprintf(s.output, "Uploading blob: %s (%s)\n", - finishedBlob.Hash[:12]+"...", - humanize.Bytes(uint64(finishedBlob.Compressed))) + s.ui.Begin("Uploading blob %s (%s) to backup destination store.", + s.ui.Hex(finishedBlob.Hash), s.ui.Size(finishedBlob.Compressed)) progressCallback := s.makeUploadProgressCallback(ctx, finishedBlob, startTime) @@ -1183,11 +1198,11 @@ func (s *Scanner) uploadBlobIfNeeded(ctx context.Context, blobPath string, blobW uploadDuration := time.Since(startTime) uploadSpeedBps := float64(finishedBlob.Compressed) / uploadDuration.Seconds() - _, _ = fmt.Fprintf(s.output, "Blob stored: %s (%s, %s/sec, %s)\n", - finishedBlob.Hash[:12]+"...", - humanize.Bytes(uint64(finishedBlob.Compressed)), - humanize.Bytes(uint64(uploadSpeedBps)), - uploadDuration.Round(time.Millisecond)) + s.ui.Complete("Uploaded blob %s (%s) in %s at %s.", + s.ui.Hex(finishedBlob.Hash), + s.ui.Size(finishedBlob.Compressed), + s.ui.Duration(uploadDuration), + s.ui.Speed(uploadSpeedBps)) log.Info("Successfully uploaded blob to storage", "path", blobPath, @@ -1236,14 +1251,15 @@ func (s *Scanner) makeUploadProgressCallback(ctx context.Context, finishedBlob * if avgSpeed > 0 { eta = time.Duration(float64(finishedBlob.Compressed-uploaded)/avgSpeed) * time.Second } - _, _ = fmt.Fprintf(s.output, " blob upload %s: %s/%s (%.0f%%) at %s/sec, blob upload elapsed %s, blob upload ETA %s\n", - finishedBlob.Hash[:12]+"...", - humanize.Bytes(uint64(uploaded)), - humanize.Bytes(uint64(finishedBlob.Compressed)), - pct, - humanize.Bytes(uint64(avgSpeed)), - totalElapsed.Round(time.Second), - eta.Round(time.Second)) + s.ui.Progress("Blob upload %s: %s / %s (%s) at %s, blob upload elapsed %s, blob upload estimated remaining time (%s), finish at %s.", + s.ui.Hex(finishedBlob.Hash), + s.ui.Size(uploaded), + s.ui.Size(finishedBlob.Compressed), + s.ui.Percent(pct), + s.ui.Speed(avgSpeed), + s.ui.Duration(totalElapsed), + s.ui.Duration(eta), + s.ui.Time(now.Add(eta))) lastStdoutTime = now } @@ -1472,7 +1488,7 @@ func (s *Scanner) detectDeletedFilesFromMap(ctx context.Context, knownFiles map[ } if result.FilesDeleted > 0 { - _, _ = fmt.Fprintf(s.output, "Found %s deleted files\n", formatNumber(result.FilesDeleted)) + s.ui.Info("Snapshot source files enumeration detected %s deleted files.", s.ui.Count(result.FilesDeleted)) } return nil @@ -1594,25 +1610,3 @@ func (s *Scanner) shouldExclude(filePath, rootPath string) bool { return false } - -// formatNumber formats a number with comma separators -func formatNumber(n int) string { - if n < 1000 { - return fmt.Sprintf("%d", n) - } - return humanize.Comma(int64(n)) -} - -// formatCompact formats a number compactly with k/M suffixes (e.g., 5.7k, 1.2M) -func formatCompact(n int) string { - if n < 1000 { - return fmt.Sprintf("%d", n) - } - if n < 10000 { - return fmt.Sprintf("%.1fk", float64(n)/1000) - } - if n < 1000000 { - return fmt.Sprintf("%.0fk", float64(n)/1000) - } - return fmt.Sprintf("%.1fM", float64(n)/1000000) -} diff --git a/internal/storage/file.go b/internal/storage/file.go index a5b3ae8..deb8c0a 100644 --- a/internal/storage/file.go +++ b/internal/storage/file.go @@ -23,9 +23,8 @@ type FileStorer struct { // Uses the real OS filesystem by default; call SetFilesystem to override for testing. func NewFileStorer(basePath string) (*FileStorer, error) { fs := afero.NewOsFs() - // Ensure base path exists if err := fs.MkdirAll(basePath, 0755); err != nil { - return nil, fmt.Errorf("creating base path: %w", err) + return nil, fmt.Errorf("file:// storage: cannot create or access %s: %w (check that the volume is mounted and writable)", basePath, err) } return &FileStorer{ fs: fs, diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..f0e8879 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,204 @@ +// Package ui provides consistent user-facing output formatting for vaultik. +// All status updates, banners, errors, and warnings printed to the user +// should go through a *Writer from this package. +// +// Message classes (see Writer methods): +// +// - Begin — operation start, left-aligned, marker "》" (white) +// - Complete— operation completion, left-aligned, marker "》" (green) +// - Info — left-aligned neutral status, marker "》" (white) +// - Notice — left-aligned important note, marker "》" (cyan) +// - Warning — left-aligned warning, full word "Warning: " (orange/yellow) +// - Error — left-aligned error, full word "ERROR: " (red) +// - Progress— indented heartbeat / per-item update, marker " 》" (white) +// - Banner — application banner line, left-aligned, no marker +// +// Value formatters (Hex, Size, Duration, Time, Path, Snapshot, Speed, +// Count, Percent) return ANSI-colored strings the caller composes into +// the message body. When color is disabled (non-TTY output or NO_COLOR +// set) all formatters return plain text. +package ui + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/dustin/go-humanize" + "golang.org/x/term" +) + +// ANSI SGR escape sequences. +const ( + ansiReset = "\033[0m" + ansiBold = "\033[1m" + ansiRed = "\033[31m" + ansiGreen = "\033[32m" + ansiYellow = "\033[33m" // used for orange "Warning:" and for durations + ansiBlue = "\033[34m" + ansiMagenta = "\033[35m" + ansiCyan = "\033[36m" + ansiWhite = "\033[37m" +) + +// Marker is the chevron prefix used for all non-error/warning lines. +const Marker = "》" + +// Writer formats and emits user-facing messages with optional ANSI color. +type Writer struct { + out io.Writer + color bool +} + +// New returns a Writer that emits to out. Color is enabled when out is a +// TTY and the NO_COLOR environment variable is unset. +// https://no-color.org/ +func New(out io.Writer) *Writer { + return &Writer{out: out, color: shouldColor(out)} +} + +// NewWithColor returns a Writer with an explicit color setting, ignoring +// TTY detection. Useful for tests and for piped output that the caller +// wants to colorize anyway. +func NewWithColor(out io.Writer, color bool) *Writer { + return &Writer{out: out, color: color} +} + +// Out returns the underlying writer. +func (w *Writer) Out() io.Writer { return w.out } + +// Color reports whether color is enabled on this writer. +func (w *Writer) Color() bool { return w.color } + +// shouldColor returns true when w is a real TTY and NO_COLOR is unset. +func shouldColor(w io.Writer) bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + f, ok := w.(*os.File) + if !ok { + return false + } + return term.IsTerminal(int(f.Fd())) +} + +// paint wraps s in the given ANSI color when color is enabled. +func (w *Writer) paint(color, s string) string { + if !w.color { + return s + } + return color + s + ansiReset +} + +// ───────────────────────── message methods ───────────────────────── + +// Begin prints an operation-start line, left-aligned with a white marker. +func (w *Writer) Begin(format string, args ...any) { + w.emit(ansiWhite, Marker, "", format, args) +} + +// Complete prints an operation-completion line in green, left-aligned. +func (w *Writer) Complete(format string, args ...any) { + w.emit(ansiGreen, Marker, ansiGreen, format, args) +} + +// Info prints a neutral status line, left-aligned with a white marker. +func (w *Writer) Info(format string, args ...any) { + w.emit(ansiWhite, Marker, "", format, args) +} + +// Notice prints an attention-worthy informational line, marker in cyan. +func (w *Writer) Notice(format string, args ...any) { + w.emit(ansiCyan, Marker, "", format, args) +} + +// Warning prints "Warning: " in orange/yellow followed by the message. +func (w *Writer) Warning(format string, args ...any) { + prefix := w.paint(ansiYellow+ansiBold, "Warning: ") + _, _ = fmt.Fprintln(w.out, prefix+fmt.Sprintf(format, args...)) +} + +// Error prints "ERROR: " in red followed by the message. Goes to the same +// writer as everything else; callers that want stderr should construct a +// separate Writer for it. +func (w *Writer) Error(format string, args ...any) { + prefix := w.paint(ansiRed+ansiBold, "ERROR: ") + _, _ = fmt.Fprintln(w.out, prefix+fmt.Sprintf(format, args...)) +} + +// Progress prints an indented heartbeat / per-item update, marker in white. +func (w *Writer) Progress(format string, args ...any) { + w.emit(ansiWhite, " "+Marker, "", format, args) +} + +// Banner prints a line with no marker, left-aligned. Used for the +// application startup banner only. +func (w *Writer) Banner(format string, args ...any) { + _, _ = fmt.Fprintln(w.out, fmt.Sprintf(format, args...)) +} + +// emit writes " \n" with the prefix painted in prefixColor +// and the body optionally painted in bodyColor (empty = no body color). +func (w *Writer) emit(prefixColor, prefix, bodyColor, format string, args []any) { + body := fmt.Sprintf(format, args...) + if bodyColor != "" { + body = w.paint(bodyColor, body) + } + _, _ = fmt.Fprintln(w.out, w.paint(prefixColor, prefix)+" "+body) +} + +// ───────────────────────── value formatters ───────────────────────── +// +// These return ANSI-colored strings the caller composes into a message +// body. When color is disabled they return plain text. + +// Hex colorizes a hex identifier (blob hash, chunk hash, snapshot id). +// Long hashes are abbreviated to first 12 chars with "...". +func (w *Writer) Hex(s string) string { + short := s + if len(s) > 12 { + short = s[:12] + "..." + } + return w.paint(ansiCyan, short) +} + +// Snapshot colorizes a snapshot ID (full, no abbreviation). +func (w *Writer) Snapshot(id string) string { + return w.paint(ansiCyan+ansiBold, id) +} + +// Path colorizes a filesystem path. +func (w *Writer) Path(p string) string { + return w.paint(ansiBlue, p) +} + +// Size colorizes a byte count using humanize.Bytes. +func (w *Writer) Size(bytes int64) string { + return w.paint(ansiMagenta, humanize.Bytes(uint64(bytes))) +} + +// Speed colorizes a byte-per-second value as "/sec". +func (w *Writer) Speed(bytesPerSec float64) string { + return w.paint(ansiMagenta, humanize.Bytes(uint64(bytesPerSec))+"/sec") +} + +// Duration colorizes a time.Duration rounded to the nearest second. +func (w *Writer) Duration(d time.Duration) string { + return w.paint(ansiYellow, d.Round(time.Second).String()) +} + +// Time colorizes an absolute time (RFC3339, second precision). +func (w *Writer) Time(t time.Time) string { + return w.paint(ansiYellow, t.Format(time.RFC3339)) +} + +// Count colorizes an integer count with thousands separators. +func (w *Writer) Count(n int) string { + return w.paint(ansiMagenta, humanize.Comma(int64(n))) +} + +// Percent colorizes a 0..100 percentage. +func (w *Writer) Percent(p float64) string { + return w.paint(ansiMagenta, fmt.Sprintf("%.1f%%", p)) +} diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go new file mode 100644 index 0000000..8c3ce76 --- /dev/null +++ b/internal/ui/ui_test.go @@ -0,0 +1,86 @@ +package ui + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func newTestWriter(color bool) (*Writer, *bytes.Buffer) { + buf := &bytes.Buffer{} + return NewWithColor(buf, color), buf +} + +func TestMessageMethodsPlain(t *testing.T) { + tests := []struct { + method string + fn func(*Writer) + want string + }{ + {"Begin", func(w *Writer) { w.Begin("starting %s", "thing") }, "》 starting thing\n"}, + {"Complete", func(w *Writer) { w.Complete("done %s", "thing") }, "》 done thing\n"}, + {"Info", func(w *Writer) { w.Info("status") }, "》 status\n"}, + {"Notice", func(w *Writer) { w.Notice("note") }, "》 note\n"}, + {"Warning", func(w *Writer) { w.Warning("oops") }, "Warning: oops\n"}, + {"Error", func(w *Writer) { w.Error("boom") }, "ERROR: boom\n"}, + {"Progress", func(w *Writer) { w.Progress("p") }, " 》 p\n"}, + {"Banner", func(w *Writer) { w.Banner("hello") }, "hello\n"}, + } + + for _, tt := range tests { + t.Run(tt.method, func(t *testing.T) { + w, buf := newTestWriter(false) + tt.fn(w) + if got := buf.String(); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestColorOutputContainsANSI(t *testing.T) { + w, buf := newTestWriter(true) + w.Error("boom") + out := buf.String() + if !strings.Contains(out, "\033[") { + t.Errorf("expected ANSI escapes in color output, got %q", out) + } + if !strings.Contains(out, "ERROR: ") { + t.Errorf("expected 'ERROR: ' text in output, got %q", out) + } +} + +func TestValueFormattersPlain(t *testing.T) { + w, _ := newTestWriter(false) + + if got := w.Hex("0123456789abcdef0123"); got != "0123456789ab..." { + t.Errorf("Hex long: got %q", got) + } + if got := w.Hex("short"); got != "short" { + t.Errorf("Hex short: got %q", got) + } + if got := w.Size(1024); got != "1.0 kB" { + t.Errorf("Size: got %q", got) + } + if got := w.Duration(90 * time.Second); got != "1m30s" { + t.Errorf("Duration: got %q", got) + } + if got := w.Count(12345); got != "12,345" { + t.Errorf("Count: got %q", got) + } + if got := w.Percent(12.34); got != "12.3%" { + t.Errorf("Percent: got %q", got) + } +} + +func TestValueFormattersColored(t *testing.T) { + w, _ := newTestWriter(true) + hex := w.Hex("0123456789abcdef0123") + if !strings.Contains(hex, "\033[") { + t.Errorf("expected ANSI in colored Hex output, got %q", hex) + } + if !strings.Contains(hex, "0123456789ab") { + t.Errorf("expected hex content in output, got %q", hex) + } +} diff --git a/internal/vaultik/helpers.go b/internal/vaultik/helpers.go index 202939f..1ecc6cb 100644 --- a/internal/vaultik/helpers.go +++ b/internal/vaultik/helpers.go @@ -17,37 +17,6 @@ type SnapshotInfo struct { CompressedSize int64 `json:"compressed_size"` } -// formatNumber formats a number with commas -func formatNumber(n int) string { - str := fmt.Sprintf("%d", n) - var result []string - for i, digit := range str { - if i > 0 && (len(str)-i)%3 == 0 { - result = append(result, ",") - } - result = append(result, string(digit)) - } - return strings.Join(result, "") -} - -// formatDuration formats a duration in a human-readable way -func formatDuration(d time.Duration) string { - if d < time.Second { - return fmt.Sprintf("%dms", d.Milliseconds()) - } - if d < time.Minute { - return fmt.Sprintf("%.1fs", d.Seconds()) - } - if d < time.Hour { - mins := int(d.Minutes()) - secs := int(d.Seconds()) % 60 - return fmt.Sprintf("%dm %ds", mins, secs) - } - hours := int(d.Hours()) - mins := int(d.Minutes()) % 60 - return fmt.Sprintf("%dh %dm", hours, mins) -} - // formatBytes formats bytes in a human-readable format func formatBytes(bytes int64) string { const unit = 1024 diff --git a/internal/vaultik/snapshot.go b/internal/vaultik/snapshot.go index 3396fdd..aed6b81 100644 --- a/internal/vaultik/snapshot.go +++ b/internal/vaultik/snapshot.go @@ -83,7 +83,7 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error { // Print overall summary if multiple snapshots if len(snapshotNames) > 1 { - v.printfStdout("\nAll %d snapshots completed in %s\n", len(snapshotNames), time.Since(overallStartTime).Round(time.Second)) + v.UI.Complete("All %d snapshots completed in %s.", len(snapshotNames), v.UI.Duration(time.Since(overallStartTime))) } if opts.Prune { @@ -101,7 +101,7 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error { // snapshot of each name is kept. func (v *Vaultik) runPostBackupPrune(snapshotNames []string, keepNewerThan string) error { log.Info("Running post-backup prune", "snapshots", snapshotNames, "keep_newer_than", keepNewerThan) - v.printlnStdout("\n=== Post-backup prune ===") + v.UI.Begin("Running post-backup prune.") purgeOpts := &SnapshotPurgeOptions{ Force: true, @@ -146,7 +146,7 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna snapshotStartTime := time.Now() if total > 1 { - v.printfStdout("\n=== Snapshot %d/%d: %s ===\n", idx, total, snapName) + v.UI.Info("Snapshot %d/%d: %s.", idx, total, snapName) } resolvedDirs, err := v.resolveSnapshotPaths(snapName) @@ -156,7 +156,7 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna scanner := v.ScannerFactory(snapshot.ScannerParams{ EnableProgress: !opts.Cron, - Output: v.Stdout, + UI: v.UI, Fs: v.Fs, Exclude: v.Config.GetExcludes(snapName), SkipErrors: opts.SkipErrors, @@ -167,7 +167,7 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna return fmt.Errorf("creating snapshot: %w", err) } log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName) - v.printfStdout("Beginning snapshot: %s\n", snapshotID) + v.UI.Begin("Creating snapshot %s.", v.UI.Snapshot(snapshotID)) stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID) if err != nil { @@ -231,7 +231,7 @@ func (v *Vaultik) scanAllDirectories(scanner *snapshot.Scanner, resolvedDirs []s } log.Info("Scanning directory", "path", dir) - v.printfStdout("Beginning directory scan (%d/%d): %s\n", i+1, len(resolvedDirs), dir) + v.UI.Begin("Enumerating snapshot source files in %s (%d of %d).", v.UI.Path(dir), i+1, len(resolvedDirs)) result, err := scanner.Scan(v.ctx, dir, snapshotID) if err != nil { return nil, fmt.Errorf("failed to scan %s: %w", dir, err) @@ -335,35 +335,36 @@ func (v *Vaultik) printSnapshotSummary(snapshotID string, startTime time.Time, s compressionRatio = 1.0 } - v.printfStdout("=== Snapshot Complete ===\n") - v.printfStdout("ID: %s\n", snapshotID) - v.printfStdout("Files: %s examined, %s to process, %s unchanged", - formatNumber(stats.totalFiles), - formatNumber(totalFilesChanged), - formatNumber(stats.totalFilesSkipped)) + v.UI.Complete("Created snapshot %s.", v.UI.Snapshot(snapshotID)) + filesMsg := fmt.Sprintf("Files: %s examined, %s to process, %s unchanged", + v.UI.Count(stats.totalFiles), + v.UI.Count(totalFilesChanged), + v.UI.Count(stats.totalFilesSkipped)) if stats.totalFilesDeleted > 0 { - v.printfStdout(", %s deleted", formatNumber(stats.totalFilesDeleted)) + filesMsg += fmt.Sprintf(", %s deleted", v.UI.Count(stats.totalFilesDeleted)) } - v.printlnStdout() - v.printfStdout("Data: %s total (%s to process)", - humanize.Bytes(uint64(totalBytesAll)), - humanize.Bytes(uint64(stats.totalBytes))) + v.UI.Info("%s.", filesMsg) + + dataMsg := fmt.Sprintf("Data: %s total (%s to process)", + v.UI.Size(totalBytesAll), + v.UI.Size(stats.totalBytes)) if stats.totalBytesDeleted > 0 { - v.printfStdout(", %s deleted", humanize.Bytes(uint64(stats.totalBytesDeleted))) + dataMsg += fmt.Sprintf(", %s deleted", v.UI.Size(stats.totalBytesDeleted)) } - v.printlnStdout() + v.UI.Info("%s.", dataMsg) + if stats.totalBlobsUploaded > 0 { - v.printfStdout("Storage: %s compressed from %s (%.2fx)\n", - humanize.Bytes(uint64(totalBlobSizeCompressed)), - humanize.Bytes(uint64(totalBlobSizeUncompressed)), + v.UI.Info("Storage: %s compressed from %s (%.2fx ratio).", + v.UI.Size(totalBlobSizeCompressed), + v.UI.Size(totalBlobSizeUncompressed), compressionRatio) - v.printfStdout("Upload: %d blobs, %s in %s (%s)\n", + v.UI.Info("Upload: %d blobs, %s in %s (%s).", stats.totalBlobsUploaded, - humanize.Bytes(uint64(stats.totalBytesUploaded)), - formatDuration(stats.uploadDuration), + v.UI.Size(stats.totalBytesUploaded), + v.UI.Duration(stats.uploadDuration), formatUploadSpeed(stats.totalBytesUploaded, stats.uploadDuration)) } - v.printfStdout("Duration: %s\n", formatDuration(snapshotDuration)) + v.UI.Info("Snapshot create duration: %s.", v.UI.Duration(snapshotDuration)) } // getSnapshotBlobSizes returns total compressed and uncompressed blob sizes for a snapshot @@ -422,11 +423,11 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error { } } if len(stale) > 0 { - v.printfStdout("\nWarning: %d local snapshot(s) not found in remote storage:\n", len(stale)) + v.UI.Warning("%d local snapshot record(s) not found in backup destination store:", len(stale)) for _, id := range stale { - v.printfStdout(" %s\n", id) + v.UI.Info("%s", v.UI.Snapshot(id)) } - v.printlnStdout("Run 'vaultik snapshot cleanup' to remove stale local records.") + v.UI.Info("Run 'vaultik snapshot cleanup' to remove stale local records.") } return nil @@ -1272,6 +1273,7 @@ type PruneResult struct { // before starting a new backup or on-demand via the prune command. func (v *Vaultik) PruneDatabase() (*PruneResult, error) { log.Info("Pruning local database: removing incomplete snapshots and orphaned data") + v.UI.Begin("Pruning local index database (removing incomplete snapshots and orphaned data).") result := &PruneResult{} @@ -1327,12 +1329,8 @@ func (v *Vaultik) PruneDatabase() (*PruneResult, error) { "orphaned_blobs", result.BlobsDeleted, ) - // Print summary - v.printfStdout("Local database prune complete:\n") - v.printfStdout(" Incomplete snapshots removed: %d\n", result.SnapshotsDeleted) - v.printfStdout(" Orphaned files removed: %d\n", result.FilesDeleted) - v.printfStdout(" Orphaned chunks removed: %d\n", result.ChunksDeleted) - v.printfStdout(" Orphaned blobs removed: %d\n", result.BlobsDeleted) + v.UI.Complete("Pruned local index database: %d incomplete snapshots, %d orphaned files, %d orphaned chunks, %d orphaned blobs removed.", + result.SnapshotsDeleted, result.FilesDeleted, result.ChunksDeleted, result.BlobsDeleted) return result, nil } diff --git a/internal/vaultik/vaultik.go b/internal/vaultik/vaultik.go index 51bc44d..854d126 100644 --- a/internal/vaultik/vaultik.go +++ b/internal/vaultik/vaultik.go @@ -15,6 +15,7 @@ import ( "sneak.berlin/go/vaultik/internal/globals" "sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/storage" + "sneak.berlin/go/vaultik/internal/ui" ) // Vaultik contains all dependencies needed for vaultik operations @@ -37,6 +38,12 @@ type Vaultik struct { Stdout io.Writer Stderr io.Writer Stdin io.Reader + + // UI is the writer for user-facing status, progress, warnings, errors. + // See package internal/ui for formatting conventions. Defaults to a + // writer wrapping Stdout; the cli layer replaces it with a discarding + // writer in --cron mode. + UI *ui.Writer } // VaultikParams contains all parameters for New that can be provided by fx @@ -83,6 +90,7 @@ func New(params VaultikParams) *Vaultik { Stdout: os.Stdout, Stderr: os.Stderr, Stdin: os.Stdin, + UI: ui.New(os.Stdout), } }