From 91581eb1953324f559b6708fb2c2ef40c4bb4d8c Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 8 Sep 2026 08:57:59 +0200 Subject: [PATCH 1/2] format: byte counts are grouped in threes A total printed as 2516582400 B. Eleven digits with nothing to hold on to, and this tool prints byte counts everywhere - it is the whole point of it, so the one number a person came for was the hardest to read. It now prints 2 516 582 400 B, in every message that names bytes: the minimum in tfg formats, the summary a run prints, what a preset says its budget is, and what tfg validate reports. A space rather than a comma. 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. Nothing in a manifest or under --json goes through here, because a number there is a number rather than a sentence, so no script anybody has written sees any of this. One guard read the old shape and had to be taught the new one, and what it said while it was wrong is worth keeping: it split the line on the first space, read "1 220 B" as one byte, and 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. Two tools outside this repository read the same line and needed the same lesson. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 14 +++++++ internal/cli/formats.go | 5 ++- internal/cli/generate.go | 14 +++++-- internal/cli/preset.go | 4 +- internal/cli/recipecmd.go | 5 ++- internal/core/humanise.go | 56 ++++++++++++++++++++++++- internal/guard/smallestaccepted_test.go | 10 ++++- 7 files changed, 95 insertions(+), 13 deletions(-) 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/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) } From f23b332d779321c0ff0f5a82572bfab62f82bccc Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 8 Sep 2026 09:20:30 +0200 Subject: [PATCH 2/2] fix: teach three guards the grouped spelling they now read CI went red on eight tests, all of them guards reading a byte count out of the command line's own output. I had written in this branch's own message that a change to a human sentence has a blast radius equal to the number of things parsing it, and then shipped without running the suite, so the branch proved its own point. Three places, three different shapes: - sizeText built the expected text with strconv.FormatInt, so it looked for "36415" in a report saying "36 415 B" and found nothing. It asks core.ExactBytes now, WITH the unit - which is stronger than what it replaced rather than merely equal, because bare digits could match inside a longer number and "36415" does match in "136415". - The boundary announcement is pinned as literal text, so it moves to the grouped spelling. - The sizes a person writes are pinned as literal text as well, and deliberately not asked of core.ExactBytes: that guard is over what a PERSON reads, so the spelling is half of what it holds, and a guard built from the same function the program prints with cannot tell the two apart. Every expected string was checked against what the program actually prints rather than worked out by hand: 1 610 612 736 B, 10 485 760 B, 1 048 576 B, 716 800 B, 0 B, 15 728 640 B. One subtest cannot run on this machine and it is not this change: 1.5gib is refused with exit 6, the free space code, because the disk has 1.1 GB left. The tool is behaving correctly and the runner has room. Co-Authored-By: Claude Opus 5 --- internal/guard/boundaryunits_test.go | 2 +- internal/guard/contains_test.go | 11 +++++++++-- internal/guard/smallfixes_test.go | 13 +++++++++---- 3 files changed, 19 insertions(+), 7 deletions(-) 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/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 {