Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/formats.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"

"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
)

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 10 additions & 4 deletions internal/cli/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/cli/preset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/recipecmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
56 changes: 55 additions & 1 deletion internal/core/humanise.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package core
import (
"fmt"
"math"
"strconv"
"strings"
"time"
)

Expand All @@ -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 {
Expand All @@ -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.
//
Expand Down
2 changes: 1 addition & 1 deletion internal/guard/boundaryunits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions internal/guard/contains_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions internal/guard/smallestaccepted_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
13 changes: 9 additions & 4 deletions internal/guard/smallfixes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading