Merge fix/clean-startup-errors

This commit is contained in:
2026-06-17 04:32:05 +02:00
16 changed files with 593 additions and 190 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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): <real error>
//
// 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

39
internal/cli/app_test.go Normal file
View File

@@ -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)
}
})
}
}

View File

@@ -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)

View File

@@ -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 <sneak@sneak.berlin>"
// 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
}

View File

@@ -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,
})

View File

@@ -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)
}

View File

@@ -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,

204
internal/ui/ui.go Normal file
View File

@@ -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 "<prefix> <body>\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 "<size>/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))
}

86
internal/ui/ui_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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),
}
}