diff --git a/CHANGELOG.md b/CHANGELOG.md index 5844e36..6db6e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,6 +191,20 @@ because it turns other people's test suites red. ### Changed +- **Byte counts are grouped in threes.** A total used to print as + `2516582400 B`. It now prints as `2 516 582 400 B`, in every message that + names a number of bytes - `tfg formats`, the summary a run prints, what a + preset says its budget is, and what `tfg validate` reports. + + Grouped with a space rather than a comma, because a comma is a thousands mark + in some countries and a decimal point in others, and this tool is read in + both. + + Machine output is untouched. `--json` and the manifest carry numbers rather + than sentences, so nothing that parses those sees any of this. If you have a + script reading a byte count out of the human output, it needs to take the + spaces out. + - **Notes are reported once per thing they say, not once per file.** A run of 25 000 one-byte text files used to print 25 001 `note:` lines, every one of them the same sentence about the label not fitting. It now prints one, with diff --git a/internal/cli/formats.go b/internal/cli/formats.go index 6b9d830..466cb9c 100644 --- a/internal/cli/formats.go +++ b/internal/cli/formats.go @@ -7,6 +7,7 @@ import ( "fmt" "io" + "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/format" ) @@ -109,8 +110,8 @@ func entryFor(d format.Descriptor) formatEntry { // list and ignore the argument, ending with 0 - so there was no way to ask what // a format accepts, and the silence looked like an answer. func describeOne(d format.Descriptor, out io.Writer) { - fmt.Fprintf(out, "%s - %s fidelity, %s deterministic, minimum %d B\n", - d.ID, d.Fidelity, d.Determinism, smallestAccepted(d)) + fmt.Fprintf(out, "%s - %s fidelity, %s deterministic, minimum %s\n", + d.ID, d.Fidelity, d.Determinism, core.ExactBytes(smallestAccepted(d))) fmt.Fprintf(out, " extension %s\n", d.Extension) fmt.Fprintf(out, " padding %s\n", d.Padding.Name) fmt.Fprintf(out, " label %s\n", d.Label) diff --git a/internal/cli/generate.go b/internal/cli/generate.go index 4e516b4..45abb57 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -351,10 +351,16 @@ func sizesFromFlags(g *generateOpts, errOut io.Writer) (sizes []int64, low, high if errors.Is(err, core.ErrBoundaryTooSmall) { // A number somebody typed, so this is USAGE rather than a problem // with a document. The end above it keeps the code it had. + // Ungrouped, unlike every other count this program prints, and the + // exception is deliberate: this echoes back the number somebody + // typed after --boundary. A message that quotes your input and + // respells it on the way is a message you have to translate before + // you can compare it with what you wrote. fmt.Fprintf(errOut, "tfg: --boundary %d B is too small - %s\n", limit, err) return nil, 0, 0, 0, ExitUsage } if err != nil { + // Ungrouped for the same reason as the line above it. fmt.Fprintf(errOut, "tfg: --boundary %d B is too large - %s\n", limit, err) return nil, 0, 0, 0, ExitRecipe } @@ -387,9 +393,9 @@ func produce(ctx context.Context, targets []engine.Target, opt engine.Options, g // Echo the exact byte count. The exact number is the point of this tool, // and it is what any other tool will show when the user goes to check the // file. - fmt.Fprintf(errOut, "%s in %s, %d B total\n", + fmt.Fprintf(errOut, "%s in %s, %s total\n", core.Count(len(planned), "file", "files"), core.Count(len(targets), "target", "targets"), - engine.TotalBytes(planned)) + core.ExactBytes(engine.TotalBytes(planned))) echoBoundaries(targets, planned, errOut) echoManifestReach(planned, errOut) @@ -530,10 +536,10 @@ func echoBoundaries(targets []engine.Target, planned []engine.PlannedFile, errOu if t.BoundaryLimit <= 0 { continue } - fmt.Fprintf(errOut, "boundary %q around %d B:\n", t.ID, t.BoundaryLimit) + fmt.Fprintf(errOut, "boundary %q around %s:\n", t.ID, core.ExactBytes(t.BoundaryLimit)) for _, f := range planned { if f.Target == t { - fmt.Fprintf(errOut, " %-26s %d B\n", f.Name, f.Plan.Bytes) + fmt.Fprintf(errOut, " %-26s %s\n", f.Name, core.ExactBytes(f.Plan.Bytes)) } } diff --git a/internal/cli/preset.go b/internal/cli/preset.go index c98b8ab..09de047 100644 --- a/internal/cli/preset.go +++ b/internal/cli/preset.go @@ -537,9 +537,9 @@ func describePreset(e *preset.Expansion, b budget, out io.Writer) { fmt.Fprintf(out, " --%-12s the global flag, this preset gives it a default\n", name) } - fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %d B total, format %s\n", + fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %s total, format %s\n", core.Count(b.Targets, "target", "targets"), core.Count(b.Files, "file", "files"), - b.Bytes, strings.Join(b.Formats, ", ")) + core.ExactBytes(b.Bytes), strings.Join(b.Formats, ", ")) for _, note := range e.Notes() { fmt.Fprintf(out, "\nnote: %s\n", note) } diff --git a/internal/cli/recipecmd.go b/internal/cli/recipecmd.go index aa8ef7d..d3a11ff 100644 --- a/internal/cli/recipecmd.go +++ b/internal/cli/recipecmd.go @@ -94,8 +94,9 @@ func validate(ctx context.Context, args []string, out, errOut io.Writer) int { }, ExitOK) } - fmt.Fprintf(out, "%s is valid: %s, %s, %d B total\n%s\n", - path, core.Count(len(rec.Targets), "target", "targets"), core.Count(len(planned), "file", "files"), engine.TotalBytes(planned), hash) + fmt.Fprintf(out, "%s is valid: %s, %s, %s total\n%s\n", + path, core.Count(len(rec.Targets), "target", "targets"), core.Count(len(planned), "file", "files"), + core.ExactBytes(engine.TotalBytes(planned)), hash) return ExitOK } diff --git a/internal/core/humanise.go b/internal/core/humanise.go index 37ac11c..f185770 100644 --- a/internal/core/humanise.go +++ b/internal/core/humanise.go @@ -3,6 +3,8 @@ package core import ( "fmt" "math" + "strconv" + "strings" "time" ) @@ -20,7 +22,10 @@ import ( func HumanBytes(n int64) string { const unit = 1024 if n < unit { - return fmt.Sprintf("%d B", n) + // Through ExactBytes rather than its own %d, so the two never spell one + // number two ways. Below 1024 there is nothing to group, which is + // exactly why this is easy to get wrong and leave wrong. + return ExactBytes(n) } div, exp := int64(unit), 0 for n/div >= unit && exp < 3 { @@ -30,6 +35,55 @@ func HumanBytes(n int64) string { return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp]) } +// ExactBytes writes a count out in full, grouped in threes, with its unit. +// +// The exact number is the point of this tool and it can never be replaced by a +// rounded one - but eleven digits in a row is a number nobody reads, and both +// surfaces printed it that way. "2516582400 B" was measured on the window's run +// panel and on four lines of the command line, and the owner's report of it was +// that the bytes are welcome and unreadable, which are both true at once. +// +// Grouped with a space rather than a comma, and that is the one choice here +// worth writing down. A comma is the thousands mark in English and the decimal +// mark for most of Europe, so "2,516" is either two and a half thousand or two +// and a half depending on who is reading - and the people who read this run it +// in every country. A space means the same thing everywhere. +// +// Machine output is untouched on purpose. Nothing in a manifest or under --json +// goes through here, because a number there is a number and not a sentence. +func ExactBytes(n int64) string { + return groupedInThrees(strconv.FormatInt(n, 10)) + " B" +} + +// groupedInThrees puts a space every three digits, counting from the right. +// +// Written out rather than reached for in a library because the one in the +// standard library is about money: golang.org/x/text/message formats to a +// LOCALE, and a locale is exactly what this must not have - the window and the +// command line have to say the same thing on a Polish desktop and an American +// one, and docs/UX.md has the surfaces agreeing as a rule rather than a hope. +func groupedInThrees(digits string) string { + sign := "" + if strings.HasPrefix(digits, "-") { + sign, digits = "-", digits[1:] + } + if len(digits) <= 3 { + return sign + digits + } + lead := len(digits) % 3 + if lead == 0 { + lead = 3 + } + var out strings.Builder + out.Grow(len(digits) + (len(digits)-1)/3) + out.WriteString(digits[:lead]) + for i := lead; i < len(digits); i += 3 { + out.WriteByte(' ') + out.WriteString(digits[i : i+3]) + } + return sign + out.String() +} + // Percent divides before multiplying where it has to, so a very large run does // not wrap on the way to a number between nought and a hundred. // diff --git a/internal/guard/boundaryunits_test.go b/internal/guard/boundaryunits_test.go index ead2004..dc15aaf 100644 --- a/internal/guard/boundaryunits_test.go +++ b/internal/guard/boundaryunits_test.go @@ -77,7 +77,7 @@ func TestABoundaryRunSaysTheNumberItBuiltAround(t *testing.T) { // The three file lines carry the byte count as well, so asking whether the // number appears at all left this green when the announcement lost it - // which the mutation runner said out loud on 2026-08-18. - if !strings.Contains(errOut, `boundary "files" around 15728640 B`) { + if !strings.Contains(errOut, `boundary "files" around 15 728 640 B`) { t.Errorf("the run built a set around 15728640 B and never says so.\n"+ "Reason: 15mb can be read two ways, and printing the byte count is what lets somebody\n"+ "whose system meant 15000000 see it before a byte is written.\nWhat it said:\n%s", out) diff --git a/internal/guard/contains_test.go b/internal/guard/contains_test.go index 5131bb6..1bb8ef3 100644 --- a/internal/guard/contains_test.go +++ b/internal/guard/contains_test.go @@ -4,11 +4,11 @@ import ( stdzip "archive/zip" "os" "path/filepath" - "strconv" "strings" "testing" "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "github.com/donislawdev/TestingFilesGenerator/internal/core" ) // onlyArchive is the single .zip the run produced. Failing when there is not @@ -56,7 +56,14 @@ func archiveMembers(t *testing.T, path string) ([]string, []int64) { return names, sizes } -func sizeText(n int64) string { return strconv.FormatInt(n, 10) } +// sizeText is a number of bytes as the command line writes it. +// +// It carries the unit as well as the digits, and both halves are load bearing. +// The digits are grouped in threes since 2026-09-08, so a guard holding +// strconv.FormatInt found nothing at all in a report saying "36 415 B" - and +// the bare digits it used to look for could match INSIDE a longer number, +// which "36415" in "136415" does. +func sizeText(n int64) string { return core.ExactBytes(n) } // "an archive holds real files of other formats" is the feature docs/ // MVP-FORMATS.md 5.7 calls the key one, and the difference between this tool diff --git a/internal/guard/smallestaccepted_test.go b/internal/guard/smallestaccepted_test.go index cc4edb7..237b915 100644 --- a/internal/guard/smallestaccepted_test.go +++ b/internal/guard/smallestaccepted_test.go @@ -48,11 +48,17 @@ func smallestPrinted(t *testing.T, id string) int64 { t.Fatalf("formats %s does not print a minimum on its first line: %q", id, first) } rest := first[i+len(marker):] - end := strings.IndexByte(rest, ' ') + // Read up to the unit rather than up to the first space, and take the + // spaces out of what is left. The digits are grouped in threes as of + // 2026-09-08, so the count itself now CONTAINS spaces - and this guard read + // "1 220 B" as one byte and then reported that the tool refuses the minimum + // it advertises. It was right about what it saw and wrong about what it + // meant, which is what a parser splitting on the wrong thing always is. + end := strings.Index(rest, " B") if end < 0 { t.Fatalf("the minimum is not followed by a unit: %q", first) } - n, err := strconv.ParseInt(rest[:end], 10, 64) + n, err := strconv.ParseInt(strings.ReplaceAll(rest[:end], " ", ""), 10, 64) if err != nil { t.Fatalf("the minimum is not a number: %q", first) } diff --git a/internal/guard/smallfixes_test.go b/internal/guard/smallfixes_test.go index e1c5f09..571a3d8 100644 --- a/internal/guard/smallfixes_test.go +++ b/internal/guard/smallfixes_test.go @@ -40,11 +40,16 @@ func TestASizeIsWrittenTheWayAPersonWritesIt(t *testing.T) { // The decimal point stays. 1.5gib is a real thing people write, and the // fix must not reach it. + // Written out grouped, the way the command line prints them since + // 2026-09-08, rather than asked of core.ExactBytes. This is a guard over + // what a PERSON reads, so the spelling is half of what it pins - and a + // guard built from the same function the program prints with cannot tell + // the two apart. accepted := map[string]string{ - "1.5gib": "1610612736", - "10mb": "10485760", - "1048576": "1048576", - "700kB": "716800", + "1.5gib": "1 610 612 736", + "10mb": "10 485 760", + "1048576": "1 048 576", + "700kB": "716 800", "0": "0", } for size, want := range accepted {