refactor: add bytesize.Format helper, use human-readable sizes everywhere
- Add Format() to pkg/bytesize for converting bytes to human-readable strings (e.g. 3609629152 → "3.4 GB") - Replace private formatImageSize() in CLI with shared bytesize.Format() - Fix images prune output: space_reclaimed now shows "3.4 GB" not raw bytes - Fix volumes list/prune output to use bytesize.Format() - Fix backup run output: SIZE_BYTES column → SIZE with human-readable values
- commit
- 64b95f633f8e760a07cf737652fe16f14434b827
- author
- brice · · 2026-04-03 08:04 UTC
- parent
- 4b72742
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +41 -28
M internal/adapters/in/cli/backup.go +3 -2
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 11 | + | "github.com/bnema/gordon/pkg/bytesize" | |
| 11 | 12 | "github.com/spf13/cobra" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| @@ -120,10 +121,10 @@ func newBackupRunCmd() *cobra.Command { | |||
| 120 | 121 | } | |
| 121 | 122 | ||
| 122 | 123 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 123 | - | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil { | |
| 124 | + | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE"); err != nil { | |
| 124 | 125 | return err | |
| 125 | 126 | } | |
| 126 | - | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, result.Backup.SizeBytes); err != nil { | |
| 127 | + | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, bytesize.Format(result.Backup.SizeBytes)); err != nil { | |
| 127 | 128 | return err | |
| 128 | 129 | } | |
| 129 | 130 | if err := w.Flush(); err != nil { | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| 10 | "github.com/bnema/gordon/internal/adapters/dto" | 10 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 11 | + | "github.com/bnema/gordon/pkg/bytesize" | |||
| 11 | "github.com/spf13/cobra" | 12 | "github.com/spf13/cobra" | ||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| @@ -120,10 +121,10 @@ func newBackupRunCmd() *cobra.Command { | |||||
| 120 | } | 121 | } | ||
| 121 | 122 | ||||
| 122 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | 123 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | ||
| 123 | - | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil { | 124 | + | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE"); err != nil { |
| 124 | return err | 125 | return err | ||
| 125 | } | 126 | } | ||
| 126 | - | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, result.Backup.SizeBytes); err != nil { | 127 | + | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, bytesize.Format(result.Backup.SizeBytes)); err != nil { |
| 127 | return err | 128 | return err | ||
| 128 | } | 129 | } | ||
| 129 | if err := w.Flush(); err != nil { | 130 | if err := w.Flush(); err != nil { | ||
M internal/adapters/in/cli/images.go +4 -20
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 16 | 16 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 17 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 18 | + | "github.com/bnema/gordon/pkg/bytesize" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | type imagesClient interface { | |
| @@ -301,7 +302,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer, json | |||
| 301 | 302 | rows = append(rows, []string{ | |
| 302 | 303 | img.Repository, | |
| 303 | 304 | img.Tag, | |
| 304 | - | formatImageSize(img.Size), | |
| 305 | + | bytesize.Format(img.Size), | |
| 305 | 306 | formatImageCreatedAt(img.Created), | |
| 306 | 307 | formatImageID(img.ID), | |
| 307 | 308 | dangling, | |
| @@ -361,7 +362,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||
| 361 | 362 | } | |
| 362 | 363 | ||
| 363 | 364 | if pruneDangling { | |
| 364 | - | if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { | |
| 365 | + | if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%s\n", resp.Runtime.DeletedCount, bytesize.Format(resp.Runtime.SpaceReclaimed)); err != nil { | |
| 365 | 366 | return err | |
| 366 | 367 | } | |
| 367 | 368 | } else { | |
| @@ -371,7 +372,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||
| 371 | 372 | } | |
| 372 | 373 | ||
| 373 | 374 | if pruneRegistry { | |
| 374 | - | err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) | |
| 375 | + | err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%s\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, bytesize.Format(resp.Registry.SpaceReclaimed)) | |
| 375 | 376 | return err | |
| 376 | 377 | } | |
| 377 | 378 | ||
| @@ -569,23 +570,6 @@ func formatImageCreatedAt(t time.Time) string { | |||
| 569 | 570 | return t.UTC().Format("2006-01-02T15:04:05Z") | |
| 570 | 571 | } | |
| 571 | 572 | ||
| 572 | - | func formatImageSize(size int64) string { | |
| 573 | - | if size <= 0 { | |
| 574 | - | return "-" | |
| 575 | - | } | |
| 576 | - | ||
| 577 | - | const unit = 1024 | |
| 578 | - | if size < unit { | |
| 579 | - | return fmt.Sprintf("%d B", size) | |
| 580 | - | } | |
| 581 | - | div, exp := int64(unit), 0 | |
| 582 | - | for n := size / unit; n >= unit; n /= unit { | |
| 583 | - | div *= unit | |
| 584 | - | exp++ | |
| 585 | - | } | |
| 586 | - | return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp]) | |
| 587 | - | } | |
| 588 | - | ||
| 589 | 573 | func formatImageID(id string) string { | |
| 590 | 574 | if strings.TrimSpace(id) == "" { | |
| 591 | 575 | return "-" | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "github.com/bnema/gordon/internal/adapters/dto" | 15 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 16 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | 16 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | ||
| 17 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| 18 | + | "github.com/bnema/gordon/pkg/bytesize" | |||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | type imagesClient interface { | 21 | type imagesClient interface { | ||
| @@ -301,7 +302,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer, json | |||||
| 301 | rows = append(rows, []string{ | 302 | rows = append(rows, []string{ | ||
| 302 | img.Repository, | 303 | img.Repository, | ||
| 303 | img.Tag, | 304 | img.Tag, | ||
| 304 | - | formatImageSize(img.Size), | 305 | + | bytesize.Format(img.Size), |
| 305 | formatImageCreatedAt(img.Created), | 306 | formatImageCreatedAt(img.Created), | ||
| 306 | formatImageID(img.ID), | 307 | formatImageID(img.ID), | ||
| 307 | dangling, | 308 | dangling, | ||
| @@ -361,7 +362,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||||
| 361 | } | 362 | } | ||
| 362 | 363 | ||||
| 363 | if pruneDangling { | 364 | if pruneDangling { | ||
| 364 | - | if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { | 365 | + | if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%s\n", resp.Runtime.DeletedCount, bytesize.Format(resp.Runtime.SpaceReclaimed)); err != nil { |
| 365 | return err | 366 | return err | ||
| 366 | } | 367 | } | ||
| 367 | } else { | 368 | } else { | ||
| @@ -371,7 +372,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||||
| 371 | } | 372 | } | ||
| 372 | 373 | ||||
| 373 | if pruneRegistry { | 374 | if pruneRegistry { | ||
| 374 | - | err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) | 375 | + | err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%s\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, bytesize.Format(resp.Registry.SpaceReclaimed)) |
| 375 | return err | 376 | return err | ||
| 376 | } | 377 | } | ||
| 377 | 378 | ||||
| @@ -569,23 +570,6 @@ func formatImageCreatedAt(t time.Time) string { | |||||
| 569 | return t.UTC().Format("2006-01-02T15:04:05Z") | 570 | return t.UTC().Format("2006-01-02T15:04:05Z") | ||
| 570 | } | 571 | } | ||
| 571 | 572 | ||||
| 572 | - | func formatImageSize(size int64) string { | |||
| 573 | - | if size <= 0 { | |||
| 574 | - | return "-" | |||
| 575 | - | } | |||
| 576 | - | ||||
| 577 | - | const unit = 1024 | |||
| 578 | - | if size < unit { | |||
| 579 | - | return fmt.Sprintf("%d B", size) | |||
| 580 | - | } | |||
| 581 | - | div, exp := int64(unit), 0 | |||
| 582 | - | for n := size / unit; n >= unit; n /= unit { | |||
| 583 | - | div *= unit | |||
| 584 | - | exp++ | |||
| 585 | - | } | |||
| 586 | - | return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp]) | |||
| 587 | - | } | |||
| 588 | - | ||||
| 589 | func formatImageID(id string) string { | 573 | func formatImageID(id string) string { | ||
| 590 | if strings.TrimSpace(id) == "" { | 574 | if strings.TrimSpace(id) == "" { | ||
| 591 | return "-" | 575 | return "-" | ||
M internal/adapters/in/cli/images_test.go +1 -1
| @@ -168,7 +168,7 @@ func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) | |||
| 168 | 168 | assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "..."))) | |
| 169 | 169 | assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "..."))) | |
| 170 | 170 | ||
| 171 | - | assert.Equal(t, "-", sizeField) | |
| 171 | + | assert.Equal(t, "0 B", sizeField) | |
| 172 | 172 | assert.Equal(t, "-", createdField) | |
| 173 | 173 | assert.Contains(t, text, "Total images: 1 (dangling: 1)") | |
| 174 | 174 | } | |
| @@ -168,7 +168,7 @@ func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) | |||||
| 168 | assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "..."))) | 168 | assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "..."))) | ||
| 169 | assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "..."))) | 169 | assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "..."))) | ||
| 170 | 170 | ||||
| 171 | - | assert.Equal(t, "-", sizeField) | 171 | + | assert.Equal(t, "0 B", sizeField) |
| 172 | assert.Equal(t, "-", createdField) | 172 | assert.Equal(t, "-", createdField) | ||
| 173 | assert.Contains(t, text, "Total images: 1 (dangling: 1)") | 173 | assert.Contains(t, text, "Total images: 1 (dangling: 1)") | ||
| 174 | } | 174 | } | ||
M internal/adapters/in/cli/volumes.go +5 -4
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 11 | 11 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 12 | + | "github.com/bnema/gordon/pkg/bytesize" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | type volumesClient interface { | |
| @@ -98,7 +99,7 @@ func runVolumesList(ctx context.Context, client volumesClient, out io.Writer, js | |||
| 98 | 99 | if len(v.Containers) > 0 { | |
| 99 | 100 | containers = fmt.Sprintf("%v", v.Containers) | |
| 100 | 101 | } | |
| 101 | - | if err := cliWritef(out, " %-30s %-10s %-10s %s\n", v.Name, status, formatImageSize(v.Size), containers); err != nil { | |
| 102 | + | if err := cliWritef(out, " %-30s %-10s %-10s %s\n", v.Name, status, bytesize.Format(v.Size), containers); err != nil { | |
| 102 | 103 | return err | |
| 103 | 104 | } | |
| 104 | 105 | } | |
| @@ -119,11 +120,11 @@ func renderPrunePreview(out io.Writer, resp *dto.VolumePruneResponse) error { | |||
| 119 | 120 | return err | |
| 120 | 121 | } | |
| 121 | 122 | for _, v := range resp.Volumes { | |
| 122 | - | if err := cliWritef(out, " %s (%s)\n", v.Name, formatImageSize(v.Size)); err != nil { | |
| 123 | + | if err := cliWritef(out, " %s (%s)\n", v.Name, bytesize.Format(v.Size)); err != nil { | |
| 123 | 124 | return err | |
| 124 | 125 | } | |
| 125 | 126 | } | |
| 126 | - | return cliWritef(out, "\n%d volumes, %s total\n", resp.VolumesRemoved, formatImageSize(resp.SpaceReclaimed)) | |
| 127 | + | return cliWritef(out, "\n%d volumes, %s total\n", resp.VolumesRemoved, bytesize.Format(resp.SpaceReclaimed)) | |
| 127 | 128 | } | |
| 128 | 129 | ||
| 129 | 130 | func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPruneOptions, out io.Writer) error { | |
| @@ -171,5 +172,5 @@ func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPrun | |||
| 171 | 172 | return writeJSON(out, result) | |
| 172 | 173 | } | |
| 173 | 174 | ||
| 174 | - | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, formatImageSize(result.SpaceReclaimed)))) | |
| 175 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, bytesize.Format(result.SpaceReclaimed)))) | |
| 175 | 176 | } | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/bnema/gordon/internal/adapters/dto" | 10 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 11 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | 11 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | ||
| 12 | + | "github.com/bnema/gordon/pkg/bytesize" | |||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 14 | type volumesClient interface { | 15 | type volumesClient interface { | ||
| @@ -98,7 +99,7 @@ func runVolumesList(ctx context.Context, client volumesClient, out io.Writer, js | |||||
| 98 | if len(v.Containers) > 0 { | 99 | if len(v.Containers) > 0 { | ||
| 99 | containers = fmt.Sprintf("%v", v.Containers) | 100 | containers = fmt.Sprintf("%v", v.Containers) | ||
| 100 | } | 101 | } | ||
| 101 | - | if err := cliWritef(out, " %-30s %-10s %-10s %s\n", v.Name, status, formatImageSize(v.Size), containers); err != nil { | 102 | + | if err := cliWritef(out, " %-30s %-10s %-10s %s\n", v.Name, status, bytesize.Format(v.Size), containers); err != nil { |
| 102 | return err | 103 | return err | ||
| 103 | } | 104 | } | ||
| 104 | } | 105 | } | ||
| @@ -119,11 +120,11 @@ func renderPrunePreview(out io.Writer, resp *dto.VolumePruneResponse) error { | |||||
| 119 | return err | 120 | return err | ||
| 120 | } | 121 | } | ||
| 121 | for _, v := range resp.Volumes { | 122 | for _, v := range resp.Volumes { | ||
| 122 | - | if err := cliWritef(out, " %s (%s)\n", v.Name, formatImageSize(v.Size)); err != nil { | 123 | + | if err := cliWritef(out, " %s (%s)\n", v.Name, bytesize.Format(v.Size)); err != nil { |
| 123 | return err | 124 | return err | ||
| 124 | } | 125 | } | ||
| 125 | } | 126 | } | ||
| 126 | - | return cliWritef(out, "\n%d volumes, %s total\n", resp.VolumesRemoved, formatImageSize(resp.SpaceReclaimed)) | 127 | + | return cliWritef(out, "\n%d volumes, %s total\n", resp.VolumesRemoved, bytesize.Format(resp.SpaceReclaimed)) |
| 127 | } | 128 | } | ||
| 128 | 129 | ||||
| 129 | func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPruneOptions, out io.Writer) error { | 130 | func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPruneOptions, out io.Writer) error { | ||
| @@ -171,5 +172,5 @@ func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPrun | |||||
| 171 | return writeJSON(out, result) | 172 | return writeJSON(out, result) | ||
| 172 | } | 173 | } | ||
| 173 | 174 | ||||
| 174 | - | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, formatImageSize(result.SpaceReclaimed)))) | 175 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, bytesize.Format(result.SpaceReclaimed)))) |
| 175 | } | 176 | } | ||
M pkg/bytesize/parse.go +28 -1
| @@ -1,4 +1,4 @@ | |||
| 1 | - | // Package bytesize provides human-friendly byte size parsing. | |
| 1 | + | // Package bytesize provides human-friendly byte size parsing and formatting. | |
| 2 | 2 | package bytesize | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| @@ -17,6 +17,33 @@ var unitMultipliers = map[string]int64{ | |||
| 17 | 17 | "TB": 1 << 40, // 1099511627776 | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | + | // Format converts a byte count into a human-friendly string. | |
| 21 | + | // | |
| 22 | + | // Uses binary (1024-based) units: B, KB, MB, GB, TB. | |
| 23 | + | // Returns one decimal place for KB and above. | |
| 24 | + | // | |
| 25 | + | // Examples: | |
| 26 | + | // | |
| 27 | + | // Format(512) // "512 B" | |
| 28 | + | // Format(1048576) // "1.0 MB" | |
| 29 | + | // Format(3609629152) // "3.4 GB" | |
| 30 | + | func Format(bytes int64) string { | |
| 31 | + | if bytes <= 0 { | |
| 32 | + | return "0 B" | |
| 33 | + | } | |
| 34 | + | units := []string{"B", "KB", "MB", "GB", "TB"} | |
| 35 | + | size := float64(bytes) | |
| 36 | + | unitIdx := 0 | |
| 37 | + | for size >= 1024 && unitIdx < len(units)-1 { | |
| 38 | + | size /= 1024 | |
| 39 | + | unitIdx++ | |
| 40 | + | } | |
| 41 | + | if unitIdx == 0 { | |
| 42 | + | return fmt.Sprintf("%d B", bytes) | |
| 43 | + | } | |
| 44 | + | return fmt.Sprintf("%.1f %s", size, units[unitIdx]) | |
| 45 | + | } | |
| 46 | + | ||
| 20 | 47 | // Parse parses a human-friendly byte size string. | |
| 21 | 48 | // | |
| 22 | 49 | // Supported units: B, KB, MB, GB, TB (case-insensitive). | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | // Package bytesize provides human-friendly byte size parsing. | 1 | + | // Package bytesize provides human-friendly byte size parsing and formatting. |
| 2 | package bytesize | 2 | package bytesize | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| @@ -17,6 +17,33 @@ var unitMultipliers = map[string]int64{ | |||||
| 17 | "TB": 1 << 40, // 1099511627776 | 17 | "TB": 1 << 40, // 1099511627776 | ||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | + | // Format converts a byte count into a human-friendly string. | |||
| 21 | + | // | |||
| 22 | + | // Uses binary (1024-based) units: B, KB, MB, GB, TB. | |||
| 23 | + | // Returns one decimal place for KB and above. | |||
| 24 | + | // | |||
| 25 | + | // Examples: | |||
| 26 | + | // | |||
| 27 | + | // Format(512) // "512 B" | |||
| 28 | + | // Format(1048576) // "1.0 MB" | |||
| 29 | + | // Format(3609629152) // "3.4 GB" | |||
| 30 | + | func Format(bytes int64) string { | |||
| 31 | + | if bytes <= 0 { | |||
| 32 | + | return "0 B" | |||
| 33 | + | } | |||
| 34 | + | units := []string{"B", "KB", "MB", "GB", "TB"} | |||
| 35 | + | size := float64(bytes) | |||
| 36 | + | unitIdx := 0 | |||
| 37 | + | for size >= 1024 && unitIdx < len(units)-1 { | |||
| 38 | + | size /= 1024 | |||
| 39 | + | unitIdx++ | |||
| 40 | + | } | |||
| 41 | + | if unitIdx == 0 { | |||
| 42 | + | return fmt.Sprintf("%d B", bytes) | |||
| 43 | + | } | |||
| 44 | + | return fmt.Sprintf("%.1f %s", size, units[unitIdx]) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 20 | // Parse parses a human-friendly byte size string. | 47 | // Parse parses a human-friendly byte size string. | ||
| 21 | // | 48 | // | ||
| 22 | // Supported units: B, KB, MB, GB, TB (case-insensitive). | 49 | // Supported units: B, KB, MB, GB, TB (case-insensitive). | ||