Gate prune's local-cleanup prose on --json and give make build a rule (closes #108)
All checks were successful
check / check (pull_request) Successful in 2m29s

CleanupLocalSnapshots wrote three prose lines to stdout with no --json
awareness, and they covered every branch of the function, so no input
avoided them: `vaultik prune --json | jq` failed even after the banner
fix. -q never helped either, because printlnStdout and stdoutf write
straight to Vaultik.Stdout and never consult Vaultik.UI, which is what
SetQuiet affects.

The issue offered three fixes and asked for a decision. Taken: thread
*PruneOptions into the function and gate each write on !opts.JSON,
matching PruneBlobs -- its sibling phase, which already takes the same
struct -- along with RemoveSnapshot and remote info, so the package has
one pattern rather than two. Rejected: moving the lines to log.Info,
because the logger's default level is slog.LevelWarn, so that would not
relocate them to stderr, it would delete them from a plain `vaultik
prune`, and the removal of rows from the local index is not something to
narrate only under --verbose. Also rejected: putting the stale-record
count into PruneBlobsResult, whose every field is blob-scoped and which
is produced by the later phase; a prune document covering both phases is
a reasonable thing to want, but that is a schema design question, not a
stream-hygiene fix. The two events are duplicated as log.Info records,
which PruneBlobs already does alongside its own prints, so they survive
on stderr under --verbose.

Also closes #110. `make build` printed "Nothing to be done for 'build'"
and exited 0 without producing a binary: build was listed in .PHONY with
no build: rule anywhere, and declaring a name phony is exactly what
converts make's "No rule to make target" error into a silent success.
Fixed with `build: vaultik`, keeping vaultik: as the file rule. Audited
all 19 .PHONY names: build was the only one without a rule, and vaultik
is correctly absent from .PHONY, being a real file target.

Tests, each verified to fail with the fix reverted rather than assumed
to. CleanupLocalSnapshots leaves stdout untouched under --json in all
three branches (stale records, none, empty index) and still emits every
line without it, so the guard cannot be satisfied by deleting the
output. prune --json runs end to end through Entry, cobra and fx over
the process's real stdout descriptor against a file:// store, asserting
exactly one JSON document, in both the stale and non-stale branches. And
a parse of the Makefile asserts every .PHONY name has a rule and that
build reaches the rule producing the binary, which keeps the audit true
for names added later; it is a parse rather than an invocation of make
because `make test` is what runs it, so shelling back into `make build`
would nest a build inside the test run. The property a parse cannot
establish -- that the recipe still fails when the build fails -- was
verified by hand against a deliberately broken tree: make build exits 2
and produces nothing.

cmd/vaultik gains its first test file, so `make test` now reports 16
packages ok where it reported 15. flagConfig and programName constants
are extracted in the CLI tests because the new argument vector pushed
"--config" and "vaultik" over goconst's threshold.

README's stdout/stderr section described the banner as "the other thing
that writes to stdout", which this defect contradicted; it now states
the contract that holds, which is that stdout under --json is the
document and nothing else, for prune as well as for the other four.
This commit is contained in:
2026-08-09 17:33:06 +00:00
parent f21e7c9e70
commit be786fe25e
9 changed files with 574 additions and 12 deletions

View File

@@ -0,0 +1,165 @@
package cli //nolint:testpackage // shares hermeticConfig and the capture helpers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/adrg/xdg"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/types"
)
// pruneJSONDocument is the shape `prune --json` writes: the
// PruneBlobsResult document, and nothing else.
//
//nolint:tagliatelle // snake_case is the established JSON output format
type pruneJSONDocument struct {
BlobsFound int `json:"blobs_found"`
BlobsDeleted int `json:"blobs_deleted"`
BytesFreed int64 `json:"bytes_freed"`
}
// stalePruneSnapshotID is seeded into the local index with no manifest
// on the destination store, which is exactly what makes it stale.
const stalePruneSnapshotID = "test-host_test_2026-04-01T09:00:00Z"
// TestEntryPruneJSONStdoutIsExactlyOneDocument is the end-to-end
// regression guard for issue #108: `vaultik prune --json | jq .` must
// work with no other flags.
//
// It runs Entry over the process's real stdout descriptor, through
// cobra and the fx graph, against a hermetic file:// destination store
// — the same construction TestEntryJSONStdoutIsExactlyOneDocument uses
// for `snapshot list`, with the pipe to jq replaced by a decoder.
//
// Both branches of the local-snapshot reconciliation are exercised
// because the three stdout writes that broke this covered all of them:
// one line per stale record and a summary when there were any, and a
// "No stale local snapshots found." line when there were none. No input
// avoided the contamination, so no single branch demonstrates the fix.
//
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
//
//nolint:paralleltest // replaces os.Args, os.Stdout and the xdg globals
func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
for _, testCase := range []struct {
name string
seedStale bool
description string
}{
{
name: "no stale local records",
seedStale: false,
description: "the empty-index branch used to print a 'No stale' line",
},
{
name: "stale local records present",
seedStale: true,
description: "the removal branch used to print a line per record " +
"plus a summary",
},
} {
t.Run(testCase.name, func(t *testing.T) {
configPath := writeHermeticPruneConfig(t, testCase.seedStale)
previousArgs := os.Args
t.Cleanup(func() {
os.Args = previousArgs
rootFlags = RootFlags{}
})
os.Args = []string{
programName, flagConfig, configPath, cmdPrune, flagJSON,
}
stdout := captureProcessStdout(t, Entry)
requireExactlyOneJSONDocument(t, stdout)
var document pruneJSONDocument
require.NoError(t, json.Unmarshal([]byte(stdout), &document),
testCase.description)
// A destination store with no blobs has none to prune. The
// assertion that matters is the one above; this one keeps the
// test honest about which document it decoded.
assert.Equal(t, 0, document.BlobsFound)
})
}
}
// writeHermeticPruneConfig builds a config over a temp directory and, if
// seedStale is set, creates the index database up front with one
// snapshot record that has no counterpart on the destination store.
// Returns the config path.
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
t.Helper()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yml")
indexPath := filepath.Join(dir, "index.sqlite")
contents := fmt.Sprintf(hermeticConfig,
filepath.Join(dir, "source"),
filepath.Join(dir, "store"),
indexPath)
require.NoError(t,
os.WriteFile(configPath, []byte(contents), configFileMode))
// The PID lock lives under xdg.DataHome, which xdg resolves at
// package init; point it at the temp dir so the test neither
// touches nor collides with the real one.
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
xdg.Reload()
t.Cleanup(xdg.Reload)
if seedStale {
seedStaleSnapshotRecord(t, indexPath)
}
return configPath
}
// seedStaleSnapshotRecord creates the index database at path and
// inserts one completed snapshot into it. Nothing is written to the
// destination store, so `prune` finds the record stale and removes it —
// the branch that printed a line per record.
func seedStaleSnapshotRecord(t *testing.T, path string) {
t.Helper()
ctx := context.Background()
db, err := database.New(ctx, path)
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
startedAt := time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC)
completedAt := startedAt.Add(time.Minute)
snap := &database.Snapshot{
ID: types.SnapshotID(stalePruneSnapshotID),
Hostname: "test-host",
VaultikVersion: "test",
StartedAt: startedAt,
CompletedAt: &completedAt,
}
repos := database.NewRepositories(db)
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
return repos.Snapshots.Create(ctx, tx, snap)
})
require.NoError(t, err)
}