From b4a539c8f9e02115373f67cd22dea04cb7ddc81a Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 22 Sep 2026 18:03:41 +0000 Subject: [PATCH] Route direct-stdout command output through internal/ui (closes #149) version, info, remote info, config, and database delete wrote plain text straight to stdout, so they were unstyled and ignored --quiet. Output now falls in two buckets, both governed by internal/ui. Status lines and confirmations (config init, config set, database delete) go through the ui message methods: styled, and --quiet silences them. The data a command exists to produce is written plain, since a marker would corrupt a table or a parsed document: the version, info, and remote info reports, the snapshot list table, config get values, and the --json documents. --quiet silences the human reports and tables but never the config get value or the --json documents, which a script depends on. The database delete confirmation prompt is always shown; it is an interactive exchange the operator must see. The pure-cli commands reach internal/ui through a small commandUI helper that builds a ui.Writer on the command's stdout in quiet mode when --quiet is set. NewForTesting now supplies a UI writer so the quiet gate is never nil. The README output-style section states the resulting rule. Model: opus-4-8 --- README.md | 31 ++- TODO.md | 15 ++ internal/cli/config.go | 42 ++-- internal/cli/config_test.go | 19 +- internal/cli/database.go | 23 ++- internal/cli/quiet_test.go | 206 +++++++++++++++++++ internal/cli/root.go | 15 ++ internal/cli/version.go | 19 +- internal/cli/version_test.go | 8 +- internal/vaultik/info.go | 31 ++- internal/vaultik/info_quiet_test.go | 30 +++ internal/vaultik/snapshot_list.go | 12 +- internal/vaultik/snapshot_list_quiet_test.go | 41 ++++ internal/vaultik/vaultik.go | 1 + 14 files changed, 427 insertions(+), 66 deletions(-) create mode 100644 internal/cli/quiet_test.go create mode 100644 internal/vaultik/info_quiet_test.go create mode 100644 internal/vaultik/snapshot_list_quiet_test.go diff --git a/README.md b/README.md index d66c7f4..9e8f862 100644 --- a/README.md +++ b/README.md @@ -645,17 +645,26 @@ priority. ## output style -The operational narration of the long-running commands — the Begin, -Complete, Progress, and status lines of `snapshot create`, `prune`, -`snapshot restore`, and the like — goes through helpers in `internal/ui` -and conforms to the uniform style below. Some commands instead write -plain text straight to stdout (`version`, `info`, `config`, the -`database delete` prompt, and the `snapshot list` table); that output is -unstyled and does not honor `--quiet`. Routing it through `internal/ui` -is tracked in -[issue #149](https://git.eeqj.de/sneak/vaultik/issues/149). Color is -enabled when stdout is a TTY and the `NO_COLOR` environment variable is -unset (https://no-color.org/). +Every command's user-facing output is governed by `internal/ui`, in one +of two ways. Color is enabled when stdout is a TTY and the `NO_COLOR` +environment variable is unset (https://no-color.org/). + +* **Status, progress, warnings, and errors** go through the `internal/ui` + message methods below: marker-prefixed, colored on a TTY, and — except + warnings and errors — silenced by `--quiet`. This is the operational + narration of the long-running commands (`snapshot create`, `prune`, + `snapshot restore`, and the like) and the confirmations of + `config init`, `config set`, and `database delete`. +* **The data a command exists to produce** is written plain, with no + marker and no color, because a marker would corrupt a table or a + parsed document. This covers the `version`, `info`, and `remote info` + reports, the `snapshot list` table, `config get` values, and every + `--json` document. `--quiet` silences the human reports and tables + (`version`, `info`, `remote info`, `snapshot list`) but never the + machine-consumed `config get` value or the `--json` documents, which a + script depends on. The `database delete` confirmation prompt is also + written this way and always shown: it is an interactive exchange the + operator must see. `internal/ui` writes to stdout; it is the output the user asked for. Structured log records are a different thing and go through diff --git a/TODO.md b/TODO.md index 005dbce..feb0f2a 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,21 @@ release" is exactly the contradiction # Completed Steps +- 2026-09-22: Routed the last direct-to-stdout command output through + `internal/ui` + ([issue #149](https://git.eeqj.de/sneak/vaultik/issues/149)). The + `version`, `info`, `remote info`, `config`, and `database delete` + commands wrote plain text straight to stdout, so they were unstyled and + ignored `--quiet`. Output now falls in two buckets, both governed by + `internal/ui`: status lines and confirmations go through its message + methods (styled, and `--quiet` silences them), while the data a command + exists to produce — the reports, the `snapshot list` table, `config get` + values, and the `--json` documents — is written plain. `--quiet` + silences the human reports and tables but never the `config get` value + or the `--json` documents, which a script depends on, and the + `database delete` confirmation prompt is always shown. The README + output-style section now states this rule. + - 2026-09-22: Validated blob hashes, offsets and lengths read back from the destination before using them ([issue #155](https://git.eeqj.de/sneak/vaultik/issues/155)). A blob diff --git a/internal/cli/config.go b/internal/cli/config.go index 9033cf6..ae8aad9 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -4,7 +4,6 @@ import ( "bytes" "errors" "fmt" - "io" "os" "os/exec" "path/filepath" @@ -13,6 +12,7 @@ import ( "github.com/spf13/cobra" "gopkg.in/yaml.v3" + "sneak.berlin/go/vaultik/internal/ui" ) // configFileMode is the permission set for freshly written config files; @@ -265,7 +265,7 @@ The config is written to the path from --config, $VAULTIK_CONFIG, or the platform default config directory (e.g. ~/Library/Application Support/ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { path := configPathForInit() _, err := os.Stat(path) @@ -285,8 +285,11 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`, return fmt.Errorf("writing config file: %w", err) } - _, _ = fmt.Fprintf(os.Stdout, "Config written to %s\n", path) - _, _ = fmt.Fprintln(os.Stdout, + // A written-confirmation, not scriptable output: route it + // through the UI so it is styled and --quiet silences it. + out := commandUI(cmd) + out.Infof("Config written to %s.", path) + out.Infof( "Edit it to set your age_recipients, snapshots, and storage_url.") return nil @@ -328,7 +331,7 @@ func newConfigGetCommand() *cobra.Command { Use: "get ", Short: "Print a config value by dotted path (e.g. storage_url, compression_level)", Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { path, err := ResolveConfigPath() if err != nil { return err @@ -344,8 +347,13 @@ func newConfigGetCommand() *cobra.Command { return err } + // The value is scriptable output: it must stay machine-plain + // (no marker, no color) and is never silenced by --quiet, so it + // is written straight to stdout rather than through the UI. + w := cmd.OutOrStdout() + if node.Kind == yaml.ScalarNode { - _, _ = fmt.Fprintln(os.Stdout, node.Value) + _, _ = fmt.Fprintln(w, node.Value) return nil } @@ -355,7 +363,7 @@ func newConfigGetCommand() *cobra.Command { return fmt.Errorf("marshaling value: %w", err) } - _, _ = fmt.Fprint(os.Stdout, string(out)) + _, _ = fmt.Fprint(w, string(out)) return nil }, @@ -377,23 +385,23 @@ Examples: vaultik config set compression_level 9 vaultik config set s3.bucket mybucket # legacy S3 fields still supported`, Args: cobra.ExactArgs(configSetArgs), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { path, err := ResolveConfigPath() if err != nil { return err } - return writeConfigSet(os.Stdout, path, args[0], args[1]) + return writeConfigSet(commandUI(cmd), path, args[0], args[1]) }, } } // writeConfigSet applies key=value to the config at path, writes it back -// owner-only, and confirms the write by printing just the key name to w. -// The value is never echoed: it may be a secret such as -// s3.secret_access_key, and captured stdout or a pasted terminal would -// then leak it. -func writeConfigSet(w io.Writer, path, key, value string) error { +// owner-only, and confirms the write by naming just the key through the +// UI writer (styled, and silenced by --quiet). The value is never +// echoed: it may be a secret such as s3.secret_access_key, and captured +// stdout or a pasted terminal would then leak it. +func writeConfigSet(out *ui.Writer, path, key, value string) error { root, err := loadYAMLFile(path) if err != nil { return err @@ -404,12 +412,12 @@ func writeConfigSet(w io.Writer, path, key, value string) error { return err } - out, err := marshalConfigYAML(root) + data, err := marshalConfigYAML(root) if err != nil { return fmt.Errorf("marshaling config: %w", err) } - err = os.WriteFile(path, out, configFileMode) + err = os.WriteFile(path, data, configFileMode) if err != nil { return fmt.Errorf("writing config file: %w", err) } @@ -425,7 +433,7 @@ func writeConfigSet(w io.Writer, path, key, value string) error { } } - _, _ = fmt.Fprintln(w, key) + out.Infof("Set %s.", key) return nil } diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 7ae0c18..b537946 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -9,6 +9,7 @@ import ( "gopkg.in/yaml.v3" "sneak.berlin/go/vaultik/internal/config" + "sneak.berlin/go/vaultik/internal/ui" ) // TestDefaultConfigTemplateParses ensures the init template is valid YAML @@ -246,19 +247,20 @@ func TestWriteConfigSetHidesSecret(t *testing.T) { t.Fatalf("seed config: %v", err) } - var out bytes.Buffer + var buf bytes.Buffer - err = writeConfigSet(&out, path, "s3.secret_access_key", secret) + err = writeConfigSet(ui.NewWithColor(&buf, false), path, + "s3.secret_access_key", secret) if err != nil { t.Fatalf("writeConfigSet: %v", err) } - if strings.Contains(out.String(), secret) { - t.Errorf("output echoed the secret value: %q", out.String()) + if strings.Contains(buf.String(), secret) { + t.Errorf("output echoed the secret value: %q", buf.String()) } - if !strings.Contains(out.String(), "s3.secret_access_key") { - t.Errorf("output did not confirm the key name: %q", out.String()) + if !strings.Contains(buf.String(), "s3.secret_access_key") { + t.Errorf("output did not confirm the key name: %q", buf.String()) } } @@ -277,9 +279,10 @@ func TestWriteConfigSetTightensMode(t *testing.T) { t.Fatalf("seed config: %v", err) } - var out bytes.Buffer + var buf bytes.Buffer - err = writeConfigSet(&out, path, "compression_level", "9") + err = writeConfigSet(ui.NewWithColor(&buf, false), path, + "compression_level", "9") if err != nil { t.Fatalf("writeConfigSet: %v", err) } diff --git a/internal/cli/database.go b/internal/cli/database.go index 3f012ba..04605df 100644 --- a/internal/cli/database.go +++ b/internal/cli/database.go @@ -48,7 +48,7 @@ storage destination on that run. Use --force to skip the confirmation prompt.`, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { // Resolve config path configPath, err := ResolveConfigPath() if err != nil { @@ -62,26 +62,31 @@ Use --force to skip the confirmation prompt.`, } dbPath := cfg.IndexPath + out := commandUI(cmd) // Check if database exists _, err = os.Stat(dbPath) if os.IsNotExist(err) { - _, _ = fmt.Fprintf(os.Stdout, "Database does not exist: %s\n", dbPath) + out.Infof("Local state database does not exist: %s.", dbPath) return nil } - // Confirm unless --force + // Confirm unless --force. The prompt and its immediate result + // are an interactive exchange the operator must see, so they go + // straight to stdout rather than through the UI and --quiet does + // not silence them. if !force { - _, _ = fmt.Fprintf(os.Stdout, + w := cmd.OutOrStdout() + _, _ = fmt.Fprintf(w, "This will delete the local state database at:\n %s\n\n", dbPath) - _, _ = fmt.Fprint(os.Stdout, "Are you sure? Type 'yes' to confirm: ") + _, _ = fmt.Fprint(w, "Are you sure? Type 'yes' to confirm: ") var confirm string _, err = fmt.Scanln(&confirm) if err != nil || confirm != "yes" { - _, _ = fmt.Fprintln(os.Stdout, "Aborted.") + _, _ = fmt.Fprintln(w, "Aborted.") //nolint:nilerr // a failed/aborted confirmation is a clean abort return nil @@ -100,11 +105,7 @@ Use --force to skip the confirmation prompt.`, _ = os.Remove(walPath) // Ignore errors - files may not exist _ = os.Remove(shmPath) - rootFlags := GetRootFlags() - if !rootFlags.Quiet { - _, _ = fmt.Fprintf(os.Stdout, "Database deleted: %s\n", dbPath) - } - + out.Infof("Local state database deleted: %s.", dbPath) log.Info("Local state database deleted", "path", dbPath) return nil diff --git a/internal/cli/quiet_test.go b/internal/cli/quiet_test.go new file mode 100644 index 0000000..6f2193c --- /dev/null +++ b/internal/cli/quiet_test.go @@ -0,0 +1,206 @@ +package cli //nolint:testpackage // sets the unexported rootFlags directly + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// setRootFlags overrides the global rootFlags for the duration of one +// test and restores it afterward. These tests must not run in parallel: +// the flags are process-global, so the whole struct is saved and put +// back rather than left mutated for the next test. +func setRootFlags(t *testing.T, f RootFlags) { + t.Helper() + + old := rootFlags + rootFlags = f + + t.Cleanup(func() { rootFlags = old }) +} + +// seedFile writes content to a fresh file and returns its path. +func seedFile(t *testing.T, dir, name, content string) string { + t.Helper() + + path := filepath.Join(dir, name) + + err := os.WriteFile(path, []byte(content), 0o600) + if err != nil { + t.Fatalf("seeding %s: %v", name, err) + } + + return path +} + +// mustExecute runs a command with its output captured and fails the test +// if it errors, returning what the command printed. +func mustExecute(t *testing.T, cmd *cobra.Command, args ...string) string { + t.Helper() + + var out bytes.Buffer + + cmd.SetOut(&out) + cmd.SetArgs(args) + + err := cmd.Execute() + if err != nil { + t.Fatalf("%s failed: %v", cmd.Name(), err) + } + + return out.String() +} + +// TestVersionQuietSuppressesReport checks that --quiet silences the whole +// version report: it is human-facing output, not a scriptable value. +// +//nolint:paralleltest // mutates the process-global rootFlags +func TestVersionQuietSuppressesReport(t *testing.T) { + setRootFlags(t, RootFlags{Quiet: true}) + + out := mustExecute(t, NewVersionCommand()) + + if out != "" { + t.Errorf("--quiet version printed %q, want nothing", out) + } +} + +// TestConfigGetIgnoresQuiet checks that a config value is printed even +// under --quiet: it is scriptable output a caller depends on, so --quiet +// must not suppress it, and it stays machine-plain (no marker, no color). +// +//nolint:paralleltest // mutates the process-global rootFlags +func TestConfigGetIgnoresQuiet(t *testing.T) { + dir := t.TempDir() + path := seedFile(t, dir, "config.yml", "storage_url: file:///mnt/x\n") + + setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path}) + + out := mustExecute(t, newConfigGetCommand(), "storage_url") + + if out != "file:///mnt/x\n" { + t.Errorf("config get --quiet = %q, want the plain value", out) + } +} + +// TestConfigSetQuietSuppressesConfirmation checks that --quiet silences +// the confirmation line while still writing the value to the file. +// +//nolint:paralleltest // mutates the process-global rootFlags +func TestConfigSetQuietSuppressesConfirmation(t *testing.T) { + dir := t.TempDir() + path := seedFile(t, dir, "config.yml", "compression_level: 3\n") + + setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path}) + + out := mustExecute(t, newConfigSetCommand(), "compression_level", "9") + + if out != "" { + t.Errorf("--quiet config set printed %q, want nothing", out) + } + + data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path + if err != nil { + t.Fatalf("reading config back: %v", err) + } + + if !strings.Contains(string(data), "compression_level: 9") { + t.Errorf("config set did not write the value under --quiet:\n%s", data) + } +} + +// TestConfigSetConfirmsWhenNotQuiet checks that the confirmation names +// the key (styled) when --quiet is not set. +// +//nolint:paralleltest // mutates the process-global rootFlags +func TestConfigSetConfirmsWhenNotQuiet(t *testing.T) { + dir := t.TempDir() + path := seedFile(t, dir, "config.yml", "compression_level: 3\n") + + setRootFlags(t, RootFlags{ConfigPath: path}) + + out := mustExecute(t, newConfigSetCommand(), "compression_level", "9") + + if !strings.Contains(out, "compression_level") { + t.Errorf("config set did not confirm the key: %q", out) + } +} + +// TestConfigInitQuietSuppressesConfirmation checks that --quiet silences +// the "config written" confirmation while still writing the file. +// +//nolint:paralleltest // mutates the process-global rootFlags +func TestConfigInitQuietSuppressesConfirmation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "new-config.yml") + + setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path}) + + out := mustExecute(t, newConfigInitCommand()) + + if out != "" { + t.Errorf("--quiet config init printed %q, want nothing", out) + } + + _, err := os.Stat(path) + if err != nil { + t.Errorf("config init did not write the file under --quiet: %v", err) + } +} + +// seedDatabaseDeleteConfig writes a valid config whose index_path is a +// seeded database file, and returns both paths. +func seedDatabaseDeleteConfig(t *testing.T, dir string) (string, string) { + t.Helper() + + dbPath := seedFile(t, dir, "index.sqlite", "not-a-real-db") + cfg := fmt.Sprintf(hermeticConfig, + filepath.Join(dir, "source"), filepath.Join(dir, "store"), dbPath) + cfgPath := seedFile(t, dir, "config.yml", cfg) + + return dbPath, cfgPath +} + +// TestDatabaseDeleteQuietSuppressesMessage checks that --quiet silences +// the "database deleted" line while still removing the file. +// +//nolint:paralleltest // mutates the process-global rootFlags +func TestDatabaseDeleteQuietSuppressesMessage(t *testing.T) { + dir := t.TempDir() + dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir) + + setRootFlags(t, RootFlags{Quiet: true, ConfigPath: cfgPath}) + + out := mustExecute(t, newDatabaseDeleteCommand(), "--force") + + if out != "" { + t.Errorf("--quiet database delete printed %q, want nothing", out) + } + + _, err := os.Stat(dbPath) + if !os.IsNotExist(err) { + t.Errorf("database delete did not remove the file: stat err = %v", err) + } +} + +// TestDatabaseDeleteReportsWhenNotQuiet checks that the deletion is +// reported when --quiet is not set. +// +//nolint:paralleltest // mutates the process-global rootFlags +func TestDatabaseDeleteReportsWhenNotQuiet(t *testing.T) { + dir := t.TempDir() + _, cfgPath := seedDatabaseDeleteConfig(t, dir) + + setRootFlags(t, RootFlags{ConfigPath: cfgPath}) + + out := mustExecute(t, newDatabaseDeleteCommand(), "--force") + + if !strings.Contains(out, "deleted") { + t.Errorf("database delete did not report the deletion: %q", out) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index fcfa60e..7f24423 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -9,6 +9,7 @@ import ( "github.com/adrg/xdg" "github.com/spf13/cobra" + "sneak.berlin/go/vaultik/internal/ui" ) // errConfigNotFound is wrapped by all config-resolution failures. @@ -81,6 +82,20 @@ func GetRootFlags() RootFlags { return rootFlags } +// commandUI returns a UI writer for a command's stdout, in quiet mode +// when the global --quiet flag is set. This is how the pure-cli +// commands (version, config, database) reach internal/ui: color follows +// the writer (a TTY gets color, a captured test buffer does not), and +// --quiet silences the same message classes it silences everywhere else. +func commandUI(cmd *cobra.Command) *ui.Writer { + w := ui.New(cmd.OutOrStdout()) + if GetRootFlags().Quiet { + w.SetQuiet(true) + } + + return w +} + // ResolveConfigPath resolves the config file path from flags, environment, or default. // Search order: --config flag, VAULTIK_CONFIG env, XDG config dir, // /etc/vaultik/config.yml. diff --git a/internal/cli/version.go b/internal/cli/version.go index a6cc20f..0307060 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -2,11 +2,11 @@ package cli import ( "fmt" - "io" "runtime" "github.com/spf13/cobra" "sneak.berlin/go/vaultik/internal/globals" + "sneak.berlin/go/vaultik/internal/ui" ) // NewVersionCommand creates the version command @@ -17,16 +17,25 @@ func NewVersionCommand() *cobra.Command { Long: `Print version, git commit, and build information for vaultik.`, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, _ []string) { - writeVersion(cmd.OutOrStdout()) + writeVersion(commandUI(cmd)) }, } return cmd } -// writeVersion prints the version report. It takes a writer rather than -// using os.Stdout directly so the output can be asserted on in tests. -func writeVersion(w io.Writer) { +// writeVersion prints the version report through the UI writer. The +// report is the output this command exists to produce, so it is written +// plain (markers would corrupt the aligned report) via the writer's +// underlying stdout; --quiet silences it like any other non-error +// output. +func writeVersion(out *ui.Writer) { + if out.Quiet() { + return + } + + w := out.Out() + _, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version) _, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit) _, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate) diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go index 1d11395..7068f9d 100644 --- a/internal/cli/version_test.go +++ b/internal/cli/version_test.go @@ -34,9 +34,9 @@ func runVersionCommand(t *testing.T) string { // the report is the version the binary was actually built with. The // test binary carries no -ldflags, so that is the "dev" default -- the // same string an untagged `make vaultik` build stamps a prefix of. +// +//nolint:paralleltest // executes a command that reads the global rootFlags func TestVersionCommandReportsBuildVersion(t *testing.T) { - t.Parallel() - out := runVersionCommand(t) wantFirst := "vaultik " + globals.Version @@ -55,9 +55,9 @@ func TestVersionCommandReportsBuildVersion(t *testing.T) { // being exactly "dev", so once untagged builds started carrying their // commit sha it would have gone silent and an unreleased binary would // have looked like a release. +// +//nolint:paralleltest // executes a command that reads the global rootFlags func TestVersionCommandFlagsDevelopmentBuild(t *testing.T) { - t.Parallel() - if !globals.IsDevVersion(globals.Version) { t.Skipf("test binary was stamped with release version %q", globals.Version) diff --git a/internal/vaultik/info.go b/internal/vaultik/info.go index 0d9476b..53e6d44 100644 --- a/internal/vaultik/info.go +++ b/internal/vaultik/info.go @@ -13,6 +13,14 @@ import ( // ShowInfo displays system and configuration information func (v *Vaultik) ShowInfo() error { + // The info report is the output this command exists to produce, so it + // is written plain (markers would corrupt the aligned report) through + // the UI writer's stdout; --quiet silences it like any other + // non-error output. + if v.UI.Quiet() { + return nil + } + // System Information v.stdoutf("=== System Information ===\n") v.stdoutf("OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH) @@ -213,7 +221,12 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error { result.StorageType = storageInfo.Type result.StorageLocation = storageInfo.Location - if !jsonOutput { + // The human report is written only when it is neither the --json + // document (which needs stdout to itself) nor silenced by --quiet. The + // scan still runs in both cases so --json still gets a full result. + showText := !jsonOutput && !v.UI.Quiet() + + if showText { v.stdoutf("=== Remote Storage ===\n") v.stdoutf("Type: %s\n", storageInfo.Type) v.stdoutf("Location: %s\n", storageInfo.Location) @@ -226,7 +239,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error { return err } - if !jsonOutput { + if showText { v.stdoutf("Downloading %d manifest(s)...\n", len(snapshotIDs)) } @@ -234,7 +247,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error { v.populateRemoteInfoResult(result, snapshotMetadata, snapshotIDs, referencedBlobs) - err = v.scanRemoteBlobStorage(result, referencedBlobs, jsonOutput) + err = v.scanRemoteBlobStorage(result, referencedBlobs, showText) if err != nil { return err } @@ -252,7 +265,9 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error { return enc.Encode(result) } - v.printRemoteInfoTable(result) + if showText { + v.printRemoteInfoTable(result) + } return nil } @@ -362,11 +377,13 @@ func (v *Vaultik) populateRemoteInfoResult( } } -// scanRemoteBlobStorage lists all blobs on remote and computes orphan stats +// scanRemoteBlobStorage lists all blobs on remote and computes orphan +// stats. showText is true only when the human report is being printed +// (not --json, not --quiet), gating the progress line. func (v *Vaultik) scanRemoteBlobStorage( - result *RemoteInfoResult, referencedBlobs map[string]int64, jsonOutput bool, + result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool, ) error { - if !jsonOutput { + if showText { v.stdoutf("Scanning blobs...\n") } diff --git a/internal/vaultik/info_quiet_test.go b/internal/vaultik/info_quiet_test.go new file mode 100644 index 0000000..00d8494 --- /dev/null +++ b/internal/vaultik/info_quiet_test.go @@ -0,0 +1,30 @@ +package vaultik_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/ui" + "sneak.berlin/go/vaultik/internal/vaultik" +) + +// TestShowInfo_QuietSuppressesReport checks that --quiet silences the +// whole info report. The report is human-facing status, not a scriptable +// value, so under --quiet the command produces nothing (and touches none +// of its dependencies, which is why this minimal instance suffices). +func TestShowInfo_QuietSuppressesReport(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + + v := &vaultik.Vaultik{ + Stdout: &out, + UI: ui.NewWithColor(&out, false), + } + v.UI.SetQuiet(true) + + require.NoError(t, v.ShowInfo()) + require.Empty(t, out.String(), + "the info report must be suppressed under --quiet") +} diff --git a/internal/vaultik/snapshot_list.go b/internal/vaultik/snapshot_list.go index e35055b..f3a0269 100644 --- a/internal/vaultik/snapshot_list.go +++ b/internal/vaultik/snapshot_list.go @@ -114,9 +114,15 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error { return encoder.Encode(snapshots) } - err = v.printSnapshotTable(snapshots) - if err != nil { - return err + // The table is the output this command exists to produce, so it is + // written plain (markers would corrupt the columns) to the UI writer's + // stdout; --quiet silences it. Reconciliation notes below go through + // the UI methods, so their warnings still emit under --quiet. + if !v.UI.Quiet() { + err = v.printSnapshotTable(snapshots) + if err != nil { + return err + } } if remoteErr == nil { diff --git a/internal/vaultik/snapshot_list_quiet_test.go b/internal/vaultik/snapshot_list_quiet_test.go new file mode 100644 index 0000000..3822568 --- /dev/null +++ b/internal/vaultik/snapshot_list_quiet_test.go @@ -0,0 +1,41 @@ +package vaultik_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/log" +) + +// TestListSnapshots_QuietSuppressesTableNotJSON is the --quiet contract +// for `snapshot list`: the human table is silenced, but the --json +// document a script depends on still emits. A local snapshot with its +// remote counterpart present is used so there are no drift notes, whose +// warnings would emit even under --quiet. +func TestListSnapshots_QuietSuppressesTableNotJSON(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + env := newListEnv(t) + + ts := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) + env.addLocal(t, listLocalID, ts) + env.addRemote(t, listLocalID, ts) + + env.v.UI.SetQuiet(true) + + // Table mode: nothing on stdout. + require.NoError(t, env.v.ListSnapshots(false)) + require.Empty(t, env.stdout.String(), + "the table must be suppressed under --quiet") + + // JSON mode: the document is still written despite the quiet UI. + env.stdout.Reset() + require.NoError(t, env.v.ListSnapshots(true)) + + rows := decodeListJSON(t, env.stdout.String()) + require.Len(t, rows, 1) + require.Equal(t, listLocalID, rows[0].ID, + "the --json document must still emit under --quiet") +} diff --git a/internal/vaultik/vaultik.go b/internal/vaultik/vaultik.go index 94b41dc..3105147 100644 --- a/internal/vaultik/vaultik.go +++ b/internal/vaultik/vaultik.go @@ -215,6 +215,7 @@ func NewForTesting(storage storage.Storer) *TestVaultik { Stdout: stdout, Stderr: stderr, Stdin: stdin, + UI: ui.NewWithColor(stdout, false), }, Stdout: stdout, Stderr: stderr,