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