From 13549a442c3d31c39a787c350fc8b2ff75a1e6db Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 09:27:29 +0000 Subject: [PATCH] Print snapshot list and purge sizes like every other command (closes #312) `snapshot list`, the `snapshot purge` listing and the `bytes` value in terminal log lines divided by 1024 while labelling the result KB or MB, so 6,329,198 bytes read 6.0 MB there and 6.3 MB in every other command. All three now use humanize.Bytes, as the rest of the program does, and the 1024-based helper in internal/vaultik is removed. The size shared by the list tests is now 5,000,000 bytes, which the two ways of counting print differently. Judgement call: the log handler's copy of the formatter is changed too, since the issue asks that every command print a byte count the same way. Model: opus-5-5 --- internal/log/tty_handler.go | 17 ++++------ internal/log/tty_handler_test.go | 9 ++--- internal/vaultik/helpers.go | 16 --------- internal/vaultik/snapshot.go | 2 +- internal/vaultik/snapshot_list.go | 6 ++-- internal/vaultik/snapshot_list_test.go | 46 ++++++++++++++++++++++++-- 6 files changed, 60 insertions(+), 36 deletions(-) diff --git a/internal/log/tty_handler.go b/internal/log/tty_handler.go index 63f0dc4..fbcef68 100644 --- a/internal/log/tty_handler.go +++ b/internal/log/tty_handler.go @@ -10,6 +10,8 @@ import ( "sync" "time" "unicode" + + "github.com/dustin/go-humanize" ) // groupSeparator joins an open group path to an attribute key. This @@ -304,18 +306,13 @@ func formatDuration(d time.Duration) string { } } -// formatBytes formats bytes in a human-readable way +// formatBytes formats a byte count with humanize.Bytes, as every command +// prints one. humanize.Bytes takes no negative count, so one is printed +// as it is. func formatBytes(b int64) string { - const unit = 1024 - if b < unit { + if b < 0 { return fmt.Sprintf("%d B", b) } - div, exp := int64(unit), 0 - for n := b / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - - return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) + return humanize.Bytes(uint64(b)) } diff --git a/internal/log/tty_handler_test.go b/internal/log/tty_handler_test.go index 694657f..8d0819d 100644 --- a/internal/log/tty_handler_test.go +++ b/internal/log/tty_handler_test.go @@ -200,7 +200,8 @@ func TestTTYHandlerWithGroupQualifiesKeys(t *testing.T) { func TestTTYHandlerByteFormattingSurvivesGrouping(t *testing.T) { t.Parallel() - const oneAndAHalfKiB = 1536 + // Printed as "1.0 MB", in powers of 1000 like every other byte count. + const oneMegabyte = 1_000_000 for name, testCase := range map[string]struct { derive func(*slog.Logger) *slog.Logger @@ -223,13 +224,13 @@ func TestTTYHandlerByteFormattingSurvivesGrouping(t *testing.T) { var buf bytes.Buffer logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions())) - testCase.derive(logger).Info("uploaded", "bytes", oneAndAHalfKiB) + testCase.derive(logger).Info("uploaded", "bytes", oneMegabyte) line := ansiEscape.ReplaceAllString(buf.String(), "") - assert.Contains(t, line, testCase.key+"=1.5 KB", + assert.Contains(t, line, testCase.key+"=1.0 MB", "a byte count must be human-readable however it is qualified") - assert.NotContains(t, line, strconv.Itoa(oneAndAHalfKiB), + assert.NotContains(t, line, strconv.Itoa(oneMegabyte), "the raw number must not survive the formatting") }) } diff --git a/internal/vaultik/helpers.go b/internal/vaultik/helpers.go index 325d8f2..c891b34 100644 --- a/internal/vaultik/helpers.go +++ b/internal/vaultik/helpers.go @@ -89,22 +89,6 @@ type SnapshotInfo struct { RemotePresent *bool `json:"remote_present"` } -// formatBytes formats bytes in a human-readable format -func formatBytes(bytes int64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} - // parseSnapshotTimestamp extracts the timestamp from a snapshot ID // Format: hostname_snapshotname_2026-01-12T14:41:15Z func parseSnapshotTimestamp(snapshotID string) (time.Time, error) { diff --git a/internal/vaultik/snapshot.go b/internal/vaultik/snapshot.go index 9b767df..f2aff52 100644 --- a/internal/vaultik/snapshot.go +++ b/internal/vaultik/snapshot.go @@ -633,7 +633,7 @@ func (v *Vaultik) confirmAndExecutePurge( v.stdoutf(" %s (%s, %s)\n", snap.ID, snap.Timestamp.Format("2006-01-02 15:04:05"), - formatBytes(snap.CompressedSize)) + ubytes(snap.CompressedSize)) } } diff --git a/internal/vaultik/snapshot_list.go b/internal/vaultik/snapshot_list.go index f3a0269..8ed7b6b 100644 --- a/internal/vaultik/snapshot_list.go +++ b/internal/vaultik/snapshot_list.go @@ -553,8 +553,8 @@ func (v *Vaultik) printSnapshotTable(snapshots []SnapshotInfo) error { if snap.LocallyTracked { id = snap.ID.String() - uncompressed = formatBytes(snap.UncompressedSize) - newChunks = formatBytes(snap.NewChunkSize) + uncompressed = ubytes(snap.UncompressedSize) + newChunks = ubytes(snap.NewChunkSize) } else { id = formatRemoteOnlyID(snap.RemoteKey) uncompressed = remoteOnlyCell @@ -564,7 +564,7 @@ func (v *Vaultik) printSnapshotTable(snapshots []SnapshotInfo) error { _, err = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", id, snap.Timestamp.Format("2006-01-02 15:04:05"), - formatBytes(snap.CompressedSize), + ubytes(snap.CompressedSize), uncompressed, newChunks) if err != nil { diff --git a/internal/vaultik/snapshot_list_test.go b/internal/vaultik/snapshot_list_test.go index 030f545..54c2d0e 100644 --- a/internal/vaultik/snapshot_list_test.go +++ b/internal/vaultik/snapshot_list_test.go @@ -212,8 +212,8 @@ const ( listConfiguredName = "home" listLocalID = "testhost_home_2026-03-01T10:00:00Z" listRemoteID = "otherhost_media_2026-03-02T11:22:33Z" - // fiveMegabytes formats as "5.0 MB" through formatBytes. - fiveMegabytes = 5 * 1024 * 1024 + // fiveMegabytes is printed as "5.0 MB" by every command. + fiveMegabytes = 5_000_000 ) // TestListSnapshots_RemoteWithoutSecretKey is the regression guard for @@ -306,6 +306,48 @@ func TestListSnapshots_RemoteOnlyRowRendering(t *testing.T) { "are not in the local index") } +// TestSnapshotListAndPurgePrintSizesLikeRemoteInfo checks that the +// `snapshot list` table and the purge listing print a byte count the way +// `remote info` does: in powers of 1000, as the MB label says. +func TestSnapshotListAndPurgePrintSizesLikeRemoteInfo(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + const wantSize = "5.0 MB" // fiveMegabytes + + env := newListEnv(t) + + // A remote-only snapshot: `snapshot list` and `remote info` both take + // its size from the manifest. + remoteKey := env.addRemote(t, listRemoteID, + time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC)) + + // A locally tracked snapshot of the same size for the purge to list. + // The purge prints the snapshot's blob size, the last argument here. + start := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) + env.addLocal(t, listLocalID, start) + env.addRemote(t, listLocalID, start) + require.NoError(t, env.v.Repositories.Snapshots.UpdateCounts( + context.Background(), nil, listLocalID, 0, 0, 0, 0, fiveMegabytes)) + + require.NoError(t, env.v.RemoteInfo(false)) + assert.Contains(t, findTableRow(t, env.stdout.String(), remoteKey[:16]), + wantSize, "remote info") + + env.stdout.Reset() + require.NoError(t, env.v.ListSnapshots(false)) + assert.Contains(t, findTableRow(t, env.stdout.String(), + ""), wantSize, "snapshot list") + + // Without --force and with nothing on stdin the purge lists what it + // would delete, reads no confirmation, and deletes nothing. + env.stdout.Reset() + require.NoError(t, env.v.PurgeSnapshotsWithOptions( + &vaultik.SnapshotPurgeOptions{OlderThan: "1d"})) + assert.Contains(t, findTableRow(t, env.stdout.String(), listLocalID), + wantSize, "snapshot purge") +} + // findTableRow returns the single output line containing needle. func findTableRow(t *testing.T, out, needle string) string { t.Helper()