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
46 changes: 41 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ jobs:
test:
name: test on ${{ matrix.os }}
# A hung job otherwise holds a runner until the GitHub default of six
# hours. Every number here is well above what the job takes today: the
# matrix runs in about a minute, the race detector took 148 s when it was
# measured, and fuzzing is given 5 minutes a target by its own loop.
# hours. Remeasured 2026-09-03, because the sentence here said "the matrix
# runs in about a minute" and had not been true for a long time: the test
# step alone takes 399 s on ubuntu, 476 s on windows and 491 s on macOS.
# The race detector took 148 s when it was measured and has its own job
# and its own numbers now, and fuzzing is given 5 minutes a target by its
# own loop.
timeout-minutes: 20
strategy:
fail-fast: false
Expand Down Expand Up @@ -244,7 +247,19 @@ jobs:
shell: bash

- name: test
run: go test -tags "$(cat .github/build-tags)" ./... -count=1
# The timeout is stated for the reason the race job and the coverage
# gate both state theirs: Go allows ten minutes PER PACKAGE by default,
# this job allows twenty for all of it, and internal/guard is one
# package holding almost every test there is. A run that went past the
# first without approaching the second would die as a stack trace out
# of whichever test happened to be running, which is what the coverage
# gate did on 2026-09-03.
#
# Measured that day, on the run that caught it: 399 s on ubuntu, 476 s
# on windows, 491 s on macOS. macOS therefore had 109 s of room under a
# limit nobody had chosen, and the same fleet was measured swinging by
# more than 25 percent between two runs of one branch.
run: go test -tags "$(cat .github/build-tags)" ./... -count=1 -timeout 18m

- name: build the command line binary
run: go build -tags "$(cat .github/build-tags)" ./cmd/tfg
Expand Down Expand Up @@ -727,10 +742,31 @@ jobs:
# package, and by default Go credits coverage only to the package
# under test - which reports 0.0% and makes the gate meaningless.
# Measured, not assumed.
#
# The timeout is stated rather than left to Go, since 2026-09-03, and
# for the same reason the race job above states its own. Go allows ten
# minutes PER PACKAGE by default while this job allows twenty for all of
# it, so internal/guard died on a limit nobody had chosen - a stack
# trace out of whichever test was running when the alarm went off,
# instead of a failure naming something.
#
# Measured on the runner rather than guessed. This step took 375 s and
# 457 s on two consecutive main runs of 2026-09-02 and 2026-09-03, which
# is 22 percent of variance on code that barely moved between them, and
# the default cuts in at 600 s. A branch adding eight seconds of
# coverage instrumented work then timed out. Eight seconds is not what
# went wrong: 457 against 600 was never a margin, and a limit that
# decides on how busy the runner is tells you nothing about the code.
#
# Atomic counters are the cost. Every statement in every internal
# package pays one, and this package renders twenty five screens and
# generates files for twenty four formats. Eighteen minutes sits under
# the job's own ceiling on purpose, so a genuinely stuck run still fails
# as a test with output rather than as a killed job without any.
run: >
go test -tags "$(cat .github/build-tags)" ./... -count=1 -covermode=atomic
-coverpkg=./internal/...,./cmd/...
-coverprofile=coverage.out
-coverprofile=coverage.out -timeout 18m

- name: gate
# The threshold lives in exactly one place, .github/coverage-threshold.
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,13 @@ jobs:
# A release built from a red tree is the one kind of release that
# cannot be taken back, because the binaries are already on somebody's
# disk. This is the same command CI runs.
run: go test -tags "$(cat .github/build-tags)" ./... -count=1
#
# Including the timeout, and that is the point of saying so. Go allows
# ten minutes PER PACKAGE by default while this job allows thirty for
# all of it, and the suite measured 399 s to 491 s across the three
# systems on 2026-09-03. A release run dying on the default would look
# like a red tree and stop a release that was fine.
run: go test -tags "$(cat .github/build-tags)" ./... -count=1 -timeout 18m

- name: what version the code says
id: version
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,36 @@ because it turns other people's test suites red.

### Breaking

- **A generated `.csv` quotes only the fields that need it, so its bytes are
different.** Sizes are unchanged. Every size that worked before still works,
every reader that took these files still takes them, and the file is still
RFC 4180.

The description column used to be quoted on every row. It is quoted now only
when it carries the separator, which is the one thing that makes a quote
necessary. The description is three to seven words and drops a separator
every third one, so a short one carries none - measured on a 4 kB table,
**9 of its 44 rows** lost their quotes.

This arrives as a new setting, `quote_style`, which takes `minimal`, `all` or
`none`:

- `minimal` is the new default and is what a spreadsheet writes.
- `all` wraps every field on every row, the header included.
- `none` wraps nothing. It also stops the description carrying the separator,
because an unquoted field cannot hold one without ending early - so this
value changes what the file says and not only how it is punctuated.

**The smallest `.csv` is 115 B rather than 117**, because the shortest row
has an empty description and an empty field needs no quotes. With
`quote_style=all` the smallest is 139 B. `tfg formats` prints the current
numbers.

**A suite pinning `.csv` hashes will go red once and then stay green.** There
is no switch back to the old bytes: they were not any of the three styles RFC
4180 describes, and carrying a fourth name for them forever costs more than
the one red run.

- **Six formats have different bytes, because the tool is built with Go 1.27
now.** Sizes are unchanged. Every size that worked before still works, every
reader that took these files still takes them, and the same sizes are
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,13 @@ reference is below it.

## 📁 Formats it generates

Twenty two, and every one is a **real file of that format** - it opens in the
Twenty four, and every one is a **real file of that format** - it opens in the
software that owns it, at the exact size you asked for:

| group | formats |
|---|---|
| 📄 **Documents** | `pdf`, `docx` (Word), `xlsx` (Excel), `pptx` (PowerPoint) |
| 🖼️ **Images** | `png`, `jpg`, `bmp`, `gif`, `ico`, `svg`, `tiff`, `webp`, `avif` |
| 🖼️ **Images** | `png`, `jpg`, `bmp`, `gif`, `ico`, `svg`, `tiff`, `webp`, `avif`, `jxl` |
| 📝 **Text and markup** | `txt`, `md`, `csv`, `json`, `xml`, `html`, `log` |
| 🗜️ **Archives** | `zip`, `targz` (`.tar.gz`) |
| 🔊 **Audio** | `wav` |
Expand Down Expand Up @@ -440,7 +440,7 @@ ignored quietly: `extends`, `with`, `policy`, `engine`, `defaults.fill`,

## 📁 Formats in detail

The twenty two formats are listed near the top of this file. Each is produced at an
The twenty four formats are listed near the top of this file. Each is produced at an
exact size and checked against independent readers before it ships - a PNG is
opened and its pixels compared, a DOCX is read back by three separate
libraries, an archive is extracted.
Expand All @@ -460,14 +460,17 @@ recipe. `tfg formats <id>` prints the allowed range or list for each:
| `pdf` | `pages`, `page_size` |
| `png`, `bmp`, `tiff`, `webp` | `width`, `height` |
| `gif` | `width`, `height`, `frames` |
| `jpg` | `width`, `height`, `quality` |
| `avif`, `jpg`, `jxl` | `width`, `height`, `quality` |
| `ico` | `width`, `height`, `embed` |
| `wav` | `sample_rate`, `bit_depth`, `channels`, `content` |
| `zip`, `targz` | `entries`, `entry_format`, `entry_size` |
| `zip` | `entries`, `entry_format`, `entry_size`, `compression`, `depth`, `directory_entries`, `password`, `encryption` |
| `targz` | `entries`, `entry_format`, `entry_size`, `compression`, `depth`, `directory_entries`, `entry_mode`, `entry_owner` |
| `docx` | `paragraphs` |
| `xlsx` | `rows`, `columns` |
| `pptx` | `slides` |
| `csv`, `json`, `xml`, `html`, `md`, `log`, `txt`, `svg` | none |
| `csv` | `delimiter`, `line_ending`, `header`, `quote_style` |
| `log` | `entry_format`, `timestamps`, `rate`, `methods`, `status_mix`, `level_mix`, `ip_version`, `line_ending` |
| `json`, `xml`, `html`, `md`, `txt`, `svg` | none |

```
tfg generate --format jpg --size 500kb --set width=1920 --set height=1080 --set quality=85
Expand Down Expand Up @@ -684,7 +687,7 @@ a valid one of its format.

Honest scope, because a tool that oversells itself wastes your afternoon.

**Working end to end:** twenty two formats, recipes, presets, the desktop window,
**Working end to end:** twenty four formats, recipes, presets, the desktop window,
`generate`, `validate`, `verify`, `cleanup`, boundary sets, archive contents,
size ranges, per format settings, manifests and every exit code above.

Expand Down
132 changes: 108 additions & 24 deletions internal/format/csvfile/csv.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ const (
// drawn from below guarantees it.
amountWidth = 9

// closingQuote ends the description. What follows it is the row ending,
// which the dialect decides, so the two are no longer one constant.
closingQuote = `"`
// quoteMark wraps a field. It is the one character RFC 4180 gives a value
// for holding a separator inside it, and how many of them a row carries is
// what quote_style decides.
quoteMark = '"'

// maxRowDigits bounds the width of the row number. A row is at least one
// byte, so a file can never hold more rows than it has bytes, and a size is
Expand All @@ -54,21 +55,23 @@ const (
maxRowDigits = 19

// fixedBeforeEnding is every byte of a row except the row number, the name
// (which also forms the address), the description and the row ending. A
// constant expression, so it cannot drift away from the template above.
// (which also forms the address), the description, the quotes and the row
// ending. A constant expression, so it cannot drift away from the template
// above.
//
// The five separators count one byte each, which is a fact about the
// separators offered rather than an assumption: every one of them is a
// single byte, and dialect.go says so where they are declared.
fixedBeforeEnding = 5 /* separators */ + len(emailDomain) + amountWidth +
len(createdDate) + 1 /* the opening quote */ + len(closingQuote)
len(createdDate)
)

// fixedWidth is fixedBeforeEnding plus the row ending, which the dialect
// decides. A CRLF row costs one byte more than an LF one, on every row, which
// is why the minimum moves with this setting.
// fixedWidth is fixedBeforeEnding plus the quotes and the row ending, both of
// which the dialect decides. A CRLF row costs one byte more than an LF one on
// every row, and quoting every field costs two bytes per column, which is why
// the minimum moves with either setting.
func fixedWidth(d dialect) int64 {
return int64(fixedBeforeEnding + len(d.eol))
return int64(fixedBeforeEnding + d.quotes.quoteBytes() + len(d.eol))
}

func init() {
Expand Down Expand Up @@ -100,8 +103,8 @@ func init() {
// name and the manifest carry it instead.
Label: format.LabelExternalOnly,
Oracle: "python-csv",
// Quoting, column count and column types come later. Declaring none of
// them now makes a recipe asking for one fail loudly.
// Column count and column types come later. Declaring neither of them
// now makes a recipe asking for one fail loudly.
Properties: properties(),
GeneratorVersion: generatorVersion,
Generator: generator{},
Expand Down Expand Up @@ -146,6 +149,7 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
"line_ending": d.lineEndingID,
"separator": string(d.sep),
"header": d.header,
"quote_style": d.quotes.id,
"columns": len(columnNames),
// Stated even though it is always false here, so a test can assert
// on it without knowing which formats carry a label internally.
Expand Down Expand Up @@ -186,6 +190,15 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error {
type rows struct {
next int64
dia dialect

// scratch holds the description while it is being asked whether it needs
// quotes. It cannot be written straight into the row, because the answer
// decides whether a quote goes in FRONT of it.
//
// Reused rather than allocated per row. A table of any size is millions of
// rows and the resource guard measures exactly that. It stays small: the
// closing row is the longest and is bounded by twice the shortest row.
scratch []byte
}

// Shortest is the smallest row this builder can close a file with: the widest
Expand Down Expand Up @@ -229,33 +242,100 @@ func (r *rows) append(dst []byte, rng *rand.Rand, want int64) []byte {
cents := rng.IntN(100)

sep := r.dia.sep
q := r.dia.quotes

dst = q.mark(dst)
dst = strconv.AppendInt(dst, r.next, 10)
dst = q.mark(dst)
dst = append(dst, sep)
dst = q.mark(dst)
dst = append(dst, name...)
dst = q.mark(dst)
dst = append(dst, sep)
dst = q.mark(dst)
dst = append(dst, name...)
dst = append(dst, emailDomain...)
dst = q.mark(dst)
dst = append(dst, sep)
dst = q.mark(dst)
dst = strconv.AppendInt(dst, int64(whole), 10)
dst = append(dst, '.')
if cents < 10 {
dst = append(dst, '0')
}
dst = strconv.AppendInt(dst, int64(cents), 10)
dst = q.mark(dst)
dst = append(dst, sep)
dst = q.mark(dst)
dst = append(dst, createdDate...)
dst = append(dst, sep, '"')
dst = q.mark(dst)
dst = append(dst, sep)

return r.appendDescription(dst, rng, want, int64(len(dst)-start))
}

// appendDescription writes the last field and ends the row.
//
// want below zero means a natural row, any other value is the exact length the
// whole row must have. used is what the row has spent already, measured rather
// than worked out beside the bytes.
func (r *rows) appendDescription(dst []byte, rng *rand.Rand, want, used int64) []byte {
if want < 0 {
dst = appendPhrase(dst, rng, 3+rng.IntN(5), sep)
} else {
// Everything written so far, plus what still has to follow.
used := int64(len(dst)-start) + int64(len(closingQuote)) + int64(len(r.dia.eol))
dst = appendFiller(dst, want-used, sep)
r.scratch = appendPhrase(r.scratch[:0], rng, 3+rng.IntN(5),
r.dia.sep, r.dia.quotes.separatorsInDescription)
return r.closeRow(dst, r.scratch)
}

// What is left for the description AND its quotes together. Which of the
// two it is comes out of fill below.
r.scratch = r.fill(r.scratch[:0], want-used-int64(len(r.dia.eol)))
return r.closeRow(dst, r.scratch)
}

// fill builds the description of the closing row so the row lands on exactly
// the length it was asked for.
//
// room is the description and its quotes together, and how it divides between
// them is the whole of this function. With "all" the quotes are certain. With
// "none" there are none. With "minimal" it depends on the description itself,
// so the quoted length is built first and MEASURED: if it carries the
// separator the quotes are earned and that is the answer, and if it does not,
// the description is built to the full room with the separator withheld, which
// leaves nothing for a quote to be needed for.
//
// Measured on 2026-09-03: the filler first carries a separator at 30 B of
// description, so the second branch is reached by the two lengths either side
// of that. It is a narrow band and it is the only place the two halves of this
// setting could have disagreed.
func (r *rows) fill(dst []byte, room int64) []byte {
q, sep := r.dia.quotes, r.dia.sep

if q.everyField {
return appendFiller(dst, room-2, sep, true)
}
if q.separatorsInDescription && room >= 2 {
dst = appendFiller(dst, room-2, sep, true)
if q.wraps(dst, sep) {
return dst
}
dst = dst[:0]
}
return appendFiller(dst, room, sep, false)
}

dst = append(dst, closingQuote...)
// closeRow puts the description into the row and ends it.
//
// It asks the setting whether these bytes carry quotes, and fill above asked
// the same question to work the length out - one question, one answer, so the
// arithmetic and the bytes cannot part company.
func (r *rows) closeRow(dst, description []byte) []byte {
if r.dia.quotes.wraps(description, r.dia.sep) {
dst = append(dst, quoteMark)
dst = append(dst, description...)
dst = append(dst, quoteMark)
} else {
dst = append(dst, description...)
}
return append(dst, r.dia.eol...)
}

Expand All @@ -268,10 +348,13 @@ func (r *rows) append(dst []byte, rng *rand.Rand, want int64) []byte {
// needs no quoting, so a description that kept dropping commas would leave a
// semicolon file never exercising the quoted path at all - the file would be
// the right size, parse everywhere, and quietly test less than the comma one.
func appendPhrase(dst []byte, rng *rand.Rand, n int, sep byte) []byte {
//
// carries is false only under quote_style none, where an unquoted field cannot
// hold a separator without ending early.
func appendPhrase(dst []byte, rng *rand.Rand, n int, sep byte, carries bool) []byte {
for i := 0; i < n; i++ {
if i > 0 {
if i%3 == 0 {
if carries && i%3 == 0 {
dst = append(dst, sep)
}
dst = append(dst, ' ')
Expand All @@ -291,11 +374,12 @@ func appendPhrase(dst []byte, rng *rand.Rand, n int, sep byte) []byte {
// A separator every fourth word, unlike every other format here, and on
// purpose: the description is a quoted field, so the padding is what makes a
// long file keep exercising the quoting rather than turning into plain words.
// It follows the dialect for the reason appendPhrase gives.
func appendFiller(dst []byte, n int64, sep byte) []byte {
// It follows the dialect for the reason appendPhrase gives, and it withholds
// the separator for the reason appendPhrase gives too.
func appendFiller(dst []byte, n int64, sep byte, carries bool) []byte {
both := string(sep) + " "
return core.AppendFiller(dst, words, n, func(i int) string {
if i%4 == 0 {
if carries && i%4 == 0 {
return both
}
return " "
Expand Down
Loading
Loading