diff --git a/CHANGELOG.md b/CHANGELOG.md
index fadf5d17..a08766bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,18 @@ because it turns other people's test suites red.
### Changed
+- **A recipe this program writes for you reads like one written by hand.**
+ Where the tool composes a recipe - the batch screen, and `tfg preset eject`
+ - a count and a size are now written as bare numbers (`size: 1024`) rather
+ than quoted (`size: "1024"`), and the entries of `targets` are indented
+ under their key. Both are what every example in the documentation looks
+ like, which matters because the header of an ejected recipe invites you to
+ edit it: a target pasted in from the documentation used to land at a
+ different indent and the file stopped parsing. A name made of digits stays
+ text, so a file called `007` keeps its name. The files a recipe produces are
+ unchanged to the byte. What does change is the `recipe_hash` recorded in the
+ manifest of a run started from the batch screen, because that hash is taken
+ from the text of the recipe.
- **The line before the second start says what was checked, not what was
guessed.** When the window's first attempt gives no window and the program
starts again with the software renderer shipped beside it (Windows), the
@@ -175,6 +187,29 @@ because it turns other people's test suites red.
### Added
+- **A second preset: `empty-and-minimal`.** It answers "does a file that is
+ valid and as small as the format allows get through?" and builds the
+ smallest legal file of every format this build has, plus a file of nought
+ bytes for every format that has a legal empty form. The whole set is 26
+ files and 32 214 B, so it checks twenty-four paths through your reader for
+ the price of thirty-two kilobytes. Run it with `tfg generate --preset
+ empty-and-minimal`, or pick it on the Presets screen.
+
+ The set comes in two groups, because two different answers are honest. Every
+ file in `minimal` is valid, so it expects `accept` - those are the positive
+ control, and if they are turned away the refusals in any other set mean
+ nothing. Every file in `empty` is legal and nought bytes long, so it expects
+ `unspecified` with the reason `size_zero`: whether an empty file should be
+ kept or turned away is your policy, and the manifest does not invent it.
+
+ `--formats` narrows the set, written as a list with commas - `--formats
+ png,jpg,gif` for an image pipeline. It takes `all` on its own for every
+ format. Two things to expect from a run: several lines about files too small
+ to carry the label this tool writes into them, which is the tool saying so
+ rather than going quiet, and, for a set built only from formats that cannot
+ be empty, a line saying it has no empty files and naming the formats that
+ can.
+
- **A recipe can build on a preset.** Two keys the recipe reader used to
refuse as not built yet now work: `extends: preset:` names the preset
and `with:` fills its parameters, written the way the flags take them
diff --git a/internal/cli/presetcmd.go b/internal/cli/presetcmd.go
index 6dd1699c..da872668 100644
--- a/internal/cli/presetcmd.go
+++ b/internal/cli/presetcmd.go
@@ -228,9 +228,10 @@ 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, %s total, format %s\n",
+ fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %s total, %s %s\n",
core.Count(b.Targets, "target", "targets"), core.Count(b.Files, "file", "files"),
- core.ExactBytes(b.Bytes), strings.Join(b.Formats, ", "))
+ core.ExactBytes(b.Bytes),
+ core.Noun(len(b.Formats), "format", "formats"), strings.Join(b.Formats, ", "))
for _, note := range e.Notes() {
fmt.Fprintf(out, "\nnote: %s\n", note)
}
diff --git a/internal/core/humanise.go b/internal/core/humanise.go
index f1857701..8e38be1b 100644
--- a/internal/core/humanise.go
+++ b/internal/core/humanise.go
@@ -126,6 +126,23 @@ func Count(n int, one, many string) string {
return fmt.Sprintf("%d %s", n, many)
}
+// Noun is the word alone in the right number, for a line that NAMES the things
+// instead of counting them: "format pdf", "formats jpg, png".
+//
+// Count above it would say the number twice - "24 formats avif, bmp, ..." - and
+// a word written flat says it wrongly. "tfg preset show" printed "format avif,
+// bmp, csv, ..." on 2026-09-22, the day the first preset covering more than one
+// format arrived, because the line had only ever seen a single value.
+//
+// The same warning as Count: nothing in the sentence may agree with the number,
+// because this has no number in it to agree with.
+func Noun(n int, one, many string) string {
+ if n == 1 {
+ return one
+ }
+ return many
+}
+
// Roughly keeps an estimate at the precision it deserves. Seconds on a two
// minute estimate are noise that changes every redraw.
func Roughly(d time.Duration) string {
diff --git a/internal/guard/actionbarheight_test.go b/internal/guard/actionbarheight_test.go
index 6350ed18..4b799164 100644
--- a/internal/guard/actionbarheight_test.go
+++ b/internal/guard/actionbarheight_test.go
@@ -169,6 +169,13 @@ func TestTheFormDoesNotMoveWhenARunStarts(t *testing.T) {
func TestWhatARunSaysComesBeforeWhatSettlingSaid(t *testing.T) {
content, w, host := screenInAWindowWithHost(t, text.TabPresets())
+ // size-boundaries by name, because this guard needs a preset whose default
+ // is a number about SOMEBODY ELSE'S system - that is what produces a note
+ // at all. The screen opens on the first preset in order, which moved the
+ // day a preset sorting earlier arrived, and that one invents nothing and so
+ // says nothing.
+ choosePreset(t, content, "size-boundaries")
+
// Nothing is filled in. A note is what the run says about a value nobody
// gave it, so leaving the settings alone is what produces one at all.
press(t, content, text.ButtonPreview())
diff --git a/internal/guard/boxwidth_test.go b/internal/guard/boxwidth_test.go
index 34e16d3d..91f08d8a 100644
--- a/internal/guard/boxwidth_test.go
+++ b/internal/guard/boxwidth_test.go
@@ -414,6 +414,11 @@ func TestOnlyAPathTakesTheWholeRow(t *testing.T) {
{text.TabRecipe(), text.SettingLabel("password")},
} {
screen := selectTab(t, host.content, named.tab)
+ if named.tab == text.TabPresets() {
+ // The box measured here belongs to size-boundaries, so the preset
+ // is named rather than left to whichever one the screen opens with.
+ choosePreset(t, screen, "size-boundaries")
+ }
layOut()
control := controlUnder(screen, named.label)
if control == nil {
diff --git a/internal/guard/bytecount_test.go b/internal/guard/bytecount_test.go
index 0997f5fb..57b9dfe0 100644
--- a/internal/guard/bytecount_test.go
+++ b/internal/guard/bytecount_test.go
@@ -83,7 +83,17 @@ func TestOnlyABoxHoldingASizeCarriesACount(t *testing.T) {
func byteCountBeside(t *testing.T, o fyne.CanvasObject, label string) *parts.ByteCount {
t.Helper()
- count := byteCountIn(fieldBox(o, label))
+ // The box is looked for first, and that is not tidiness. fieldBox answers
+ // with a typed nil when nothing is labelled that way, which is not nil as
+ // an interface - so it walked into the tree walker and took the whole test
+ // binary down with a nil dereference on 2026-09-22, in a panic naming
+ // whichever test happened to be running. A guard that cannot find its box
+ // has to say so in a sentence.
+ box := fieldBox(o, label)
+ if box == nil {
+ t.Fatalf("no field is labelled %q on this screen, so nothing beside it can be counted", label)
+ }
+ count := byteCountIn(box)
if count == nil {
t.Fatalf("there is no count of bytes beside %q", label)
}
@@ -137,6 +147,9 @@ func TestADeclaredSizeSaysWhatItComesToOnEveryScreenThatDrawsOne(t *testing.T) {
t.Run("a preset parameter", func(t *testing.T) {
_, content := presetScreen(t)
+ // The parameter measured here is a size, and size-boundaries is the
+ // preset that declares one.
+ choosePreset(t, content, "size-boundaries")
label := text.SettingLabel("limit")
count := byteCountBeside(t, content, label)
diff --git a/internal/guard/compose_test.go b/internal/guard/compose_test.go
index a6a9c35c..3a3135b7 100644
--- a/internal/guard/compose_test.go
+++ b/internal/guard/compose_test.go
@@ -32,6 +32,80 @@ import (
// legal has to arrive as itself, byte for byte, because a value quietly altered
// on the way in is untouchable rule 6 - silence - with the tool doing the
// altering.
+// A composed recipe looks like one a person would have written by hand.
+//
+// It matters because of what the header of an ejected recipe promises: "edit
+// it, commit it, it is an ordinary recipe from here on". Somebody then pastes
+// a target into it, copied from docs/RECIPE.md - and every example there is
+// indented under its key and writes its numbers bare. A document composed with
+// the marshaller's flat default took that paste and stopped parsing, with the
+// error pointing at the line the person had just added. Caught on 2026-09-22 by
+// TestARecipeBuildingOnAPresetGivesTheBytesOfTheEjectedOneWithItsTargetsAppended,
+// which appends a target the way a person would.
+//
+// The last two cases are the ones that keep this honest rather than merely
+// tidy. A name is text even when it is made of digits, so a file called 123
+// must not become the number one hundred and twenty three.
+//
+// Both spellings are here for a measured reason. "007" alone looked like the
+// same assertion and was not: bareNumber refuses a leading zero on its own
+// account, so a mutation applying it to the name left "007" untouched and this
+// guard stayed green - NOT CAUGHT on 2026-09-22, an entry that found its
+// pattern, compiled, and proved nothing. "123" is the spelling that actually
+// moves, and "007" stays beside it because the two failures are different: one
+// is the tidying reaching a field it should not, the other is the tidying
+// keeping a spelling it should not.
+func TestAComposedRecipeIsWrittenTheWayAPersonWritesOne(t *testing.T) {
+ source, err := recipe.Compose(recipe.Document{
+ Targets: []recipe.TargetDraft{
+ {ID: "first", Format: "txt", Count: "2", Size: "1024", Name: "007", Group: "g"},
+ {ID: "second", Format: "txt", Count: "1", Size: "2mb", Name: "later.txt"},
+ {ID: "third", Format: "txt", Count: "1", Size: "512", Name: "123"},
+ },
+ })
+ if err != nil {
+ t.Fatalf("composing refused a document with nothing wrong in it: %v", err)
+ }
+ got := string(source)
+
+ for _, want := range []string{
+ // Indented under its key, which is what every recipe in the documents
+ // looks like and what a pasted target has to line up with.
+ "targets:\n - id: first\n",
+ // Bare, because a person writing this by hand writes count: 2.
+ "count: 2\n",
+ "size: 1024\n",
+ // Text, because it is text: a size may be written 2mb and a name may be
+ // made of digits.
+ "size: 2mb\n",
+ `name: "007"`,
+ `name: "123"`,
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("a composed recipe does not hold %q.\nIt reads:\n%s", want, got)
+ }
+ }
+
+ // And it still parses, which is the point of all of the above.
+ if _, err := recipe.Parse(source, "composed.yaml"); err != nil {
+ t.Errorf("a composed recipe does not read back: %v\n%s", err, got)
+ }
+
+ // The names survived the round trip as text rather than as numbers.
+ back, err := recipe.Parse(source, "composed.yaml")
+ if err != nil {
+ return
+ }
+ for i, want := range []string{"007", "later.txt", "123"} {
+ if i >= len(back.Targets) {
+ t.Fatalf("the recipe came back with %d targets and three went in", len(back.Targets))
+ }
+ if got := back.Targets[i].Name; got != want {
+ t.Errorf("a name came back as %q rather than %q - a number took a file's name", got, want)
+ }
+ }
+}
+
func TestARecipeComposedFromTypedTextSurvivesAnythingTypedIntoIt(t *testing.T) {
hostile := []struct {
name string
diff --git a/internal/guard/extends_test.go b/internal/guard/extends_test.go
index c383bfb2..9fa92ab0 100644
--- a/internal/guard/extends_test.go
+++ b/internal/guard/extends_test.go
@@ -191,6 +191,13 @@ func TestARecipeBuiltOnAPresetFromTheWindowGivesTheBytesTheFileGives(t *testing.
t.Fatal("there is no switch to build on a preset on the batch screen")
}
switchOn.SetChecked(true)
+ // Named rather than left to the section's opening choice, which is the
+ // first preset in order and moved the day one sorting earlier arrived.
+ //
+ // Reached through the tree rather than through the registry, because the
+ // control registered at "extends" is the menu inside its width wrapper.
+ // The fields are taken AFTER the choice, because choosing rebuilds them.
+ chooserUnder(t, content, text.FieldBasePreset()).SetSelected("size-boundaries")
fields := screen.Fields()
setBox(t, fields, recipe.KeyWith+".limit", "4mb")
chooserIn(t, fields, recipe.KeyWith+".format").SetSelected("txt")
@@ -258,6 +265,8 @@ func TestTheBatchScreenCanRunAPresetsSetAlone(t *testing.T) {
t.Error("with no batch left, the keyboard does not start at the switch")
}
+ // Named rather than left to the section's opening choice. See above.
+ chooserUnder(t, content, text.FieldBasePreset()).SetSelected("size-boundaries")
fields := screen.Fields()
setBox(t, fields, recipe.KeyWith+".limit", "4mb")
chooserIn(t, fields, recipe.KeyWith+".format").SetSelected("txt")
diff --git a/internal/guard/fieldmarking_test.go b/internal/guard/fieldmarking_test.go
index 21c13a09..e30b2218 100644
--- a/internal/guard/fieldmarking_test.go
+++ b/internal/guard/fieldmarking_test.go
@@ -95,6 +95,7 @@ func TestTheMarkGoesWhenTheValueIsFixed(t *testing.T) {
// left undone the last time a refusal moved.
func TestTheBoxARefusalIsAboutIsMarkedOnThePresetScreenToo(t *testing.T) {
_, content := presetScreen(t)
+ choosePreset(t, content, "size-boundaries")
fill(t, content, text.SettingLabel("limit"), "512")
press(t, content, "Preview")
@@ -167,6 +168,7 @@ func TestTheMenuTheKeyboardIsInDrawsALine(t *testing.T) {
// A refusal outranks the keyboard, because one of the two stops the run.
func TestARefusedBoxStaysRedWhileTheKeyboardIsInIt(t *testing.T) {
_, content := presetScreen(t)
+ choosePreset(t, content, "size-boundaries")
fill(t, content, text.SettingLabel("limit"), "512")
press(t, content, "Preview")
diff --git a/internal/guard/filekind_test.go b/internal/guard/filekind_test.go
index ca87537b..353778b1 100644
--- a/internal/guard/filekind_test.go
+++ b/internal/guard/filekind_test.go
@@ -35,6 +35,7 @@ func TestThePresetScreenCanBuildTheSetInAnyFormat(t *testing.T) {
dir := t.TempDir()
host, content := presetScreen(t)
+ choosePreset(t, content, "size-boundaries")
fill(t, content, text.FieldOutputDir(), dir)
fill(t, content, text.SettingLabel("limit"), "2mb")
choose(t, content, text.SettingLabel("format"), "png")
@@ -88,6 +89,7 @@ func TestChoosingTheFormatGivesTheSameSetOnBothSurfaces(t *testing.T) {
}
host, content := presetScreen(t)
+ choosePreset(t, content, "size-boundaries")
fill(t, content, text.FieldOutputDir(), fromWindow)
fill(t, content, text.SettingLabel("limit"), "2mb")
choose(t, content, text.SettingLabel("format"), "png")
@@ -157,6 +159,9 @@ func TestThePreviewSaysWhatKindOfFilesItWouldWrite(t *testing.T) {
}
presetHost, presets := presetScreen(t)
+ // size-boundaries by name: it is the preset that reads the global format
+ // flag, so it is the one whose screen carries a format menu at all.
+ choosePreset(t, presets, "size-boundaries")
fill(t, presets, text.FieldOutputDir(), t.TempDir())
choose(t, presets, text.SettingLabel("format"), "wav")
press(t, presets, "Preview")
diff --git a/internal/guard/menudefault_test.go b/internal/guard/menudefault_test.go
index dc6a71d0..20a57504 100644
--- a/internal/guard/menudefault_test.go
+++ b/internal/guard/menudefault_test.go
@@ -159,7 +159,16 @@ func TestEveryMenuOfferingEveryFormatDrawsTheKindPictures(t *testing.T) {
// hide behind a screen with two, which is the shape of the defect itself -
// two menus were missed for twenty days while a third had the pictures.
for _, tab := range []string{text.TabOneTarget(), text.TabRecipe(), text.TabPresets()} {
- if n := look(tab, tabNamed(t, host.content, tab)); n != 1 {
+ root := tabNamed(t, host.content, tab)
+ if tab == text.TabPresets() {
+ // The menu counted here belongs to the preset that reads the global
+ // format flag, and only size-boundaries does. The screen opens on
+ // the first preset in order, which moved the day one sorting
+ // earlier arrived - and a preset declaring no menu would leave this
+ // guard counting nothing on a screen that has one.
+ choosePreset(t, root, "size-boundaries")
+ }
+ if n := look(tab, root); n != 1 {
t.Errorf("the %s screen has %d menu(s) offering every format and this guard expects 1", tab, n)
}
}
diff --git a/internal/guard/minimalset_test.go b/internal/guard/minimalset_test.go
new file mode 100644
index 00000000..832bdfbc
--- /dev/null
+++ b/internal/guard/minimalset_test.go
@@ -0,0 +1,257 @@
+package guard
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/format"
+ _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all"
+ "github.com/donislawdev/TestingFilesGenerator/internal/preset"
+ "github.com/donislawdev/TestingFilesGenerator/internal/recipe"
+)
+
+// The empty-and-minimal set asks every format for the smallest size it takes,
+// and carries a separate empty file for every format that has one.
+//
+// The set is the whole product here, so this asserts what is IN it rather than
+// that it expanded. Three claims, and each one is a way the set could go quietly
+// wrong:
+//
+// Every file sits exactly on its format's floor. A size one byte above would
+// still run, still verify and still look like a set about minimums - and would
+// stop being the thing the preset promises.
+//
+// A format whose floor is nought bytes gets TWO files: one of a byte for the
+// positive control, one of nothing for the half nobody can promise an answer
+// for. One entry each would mean either dropping those formats out of the
+// control or giving an empty file an expectation nobody can back, which is
+// untouchable rule 5.
+//
+// And the count of empty files equals the count of formats that can be empty,
+// asserted from the registry rather than from the number two. Two is today's
+// answer, md and txt, and a third format reaching nought would otherwise leave
+// this guard green while the set silently changed shape.
+func TestTheMinimalSetSitsOnEveryFormatsFloor(t *testing.T) {
+ expanded, err := preset.Expand("empty-and-minimal", preset.Args{})
+ if err != nil {
+ t.Fatalf("the preset refused its own defaults: %v", err)
+ }
+ doc, err := recipe.Parse(expanded.Source, "empty-and-minimal.yaml")
+ if err != nil {
+ t.Fatalf("the preset wrote a recipe this build cannot read: %v\n%s", err, expanded.Source)
+ }
+
+ canBeEmpty := map[string]bool{}
+ for _, id := range format.IDs() {
+ desc, err := format.Get(id)
+ if err != nil {
+ t.Fatalf("the registry lists %s and then does not have it: %v", id, err)
+ }
+ if desc.SmallestAccepted(format.Request{Label: true}) == 0 {
+ canBeEmpty[id] = true
+ }
+ }
+ if len(canBeEmpty) == 0 {
+ t.Fatal("no format in this build reaches nought bytes, so the empty half of this set " +
+ "cannot exist and this guard is asserting nothing")
+ }
+
+ sizes := map[string]int64{}
+ empties := map[string]bool{}
+ for _, target := range doc.Targets {
+ desc, err := format.Get(target.Format)
+ if err != nil {
+ t.Fatalf("the set holds a target in format %q, which this build does not have", target.Format)
+ }
+ floor := desc.SmallestAccepted(format.Request{Label: true})
+
+ // The settled sizes rather than the text, because the text is what the
+ // recipe wrote and these are what the run will produce. One file per
+ // target here, so a target holding several sizes is itself a failure.
+ if len(target.Sizes) != 1 {
+ t.Errorf("the target %s holds %d sizes and every file of this set is one file",
+ target.ID, len(target.Sizes))
+ continue
+ }
+ size := target.Sizes[0]
+
+ if size == 0 {
+ empties[target.Format] = true
+ if !canBeEmpty[target.Format] {
+ t.Errorf("the set holds an empty %s and %s cannot be empty - its floor is %d B",
+ target.Format, target.Format, floor)
+ }
+ if target.Expected != "unspecified" || target.ExpectedReason != "size_zero" {
+ t.Errorf("the empty %s expects %q for %q - an empty file is legal and what to do "+
+ "with it is the application's policy, so it has to be unspecified with size_zero",
+ target.Format, target.Expected, target.ExpectedReason)
+ }
+ continue
+ }
+
+ if target.Expected != "accept" {
+ t.Errorf("the smallest valid %s expects %q - it is a valid file, so it is the positive "+
+ "control and has to be accept", target.Format, target.Expected)
+ }
+ want := floor
+ if want == 0 {
+ // A format whose floor is nought still needs a file with something
+ // in it, or it drops out of the positive control.
+ want = 1
+ }
+ if size != want {
+ t.Errorf("the minimal %s is %d B and the smallest this build takes is %d B",
+ target.Format, size, want)
+ }
+ sizes[target.Format] = size
+ }
+
+ if len(sizes) != len(format.IDs()) {
+ t.Errorf("the set covers %d formats and this build has %d - every format is one path "+
+ "through somebody's reader", len(sizes), len(format.IDs()))
+ }
+ if len(empties) != len(canBeEmpty) {
+ t.Errorf("the set holds %d empty files and %d formats in this build can be empty",
+ len(empties), len(canBeEmpty))
+ }
+ // The targets themselves, because the two counts above are counts of MAPS
+ // keyed by format - a second minimal png would overwrite the first and
+ // leave both of them reading exactly as they do now. Named by CodeRabbit on
+ // 2026-09-22, and it is the shape this project calls a guard that stopped
+ // reaching the state it guards.
+ if want := len(format.IDs()) + len(canBeEmpty); len(doc.Targets) != want {
+ t.Errorf("the set holds %d targets and %d were expected - one per format, plus one more "+
+ "for every format that can be empty", len(doc.Targets), want)
+ }
+}
+
+// The same formats in two orders build the same set.
+//
+// The order somebody types is not part of what they asked for. It used to be:
+// "--formats png,zip" and "--formats zip,png" produced different recipe text,
+// so the same selection carried two different recipe_hash values into two
+// manifests and listed the files the other way round. Measured on 2026-09-22 -
+// f76a3883e against 073157029 - after a comment in this package had claimed
+// registry order for weeks without anything walking the registry.
+//
+// The bytes of the files never moved, because a seed comes from the id of a
+// target rather than from its place in the list. That is what made this quiet:
+// every file was right and only the record of them disagreed.
+func TestTheMinimalSetIsTheSameWhateverOrderTheFormatsAreNamedIn(t *testing.T) {
+ // Two formats far apart in the registry, so a walk that kept the typing
+ // cannot pass by accident.
+ first, err := preset.Expand("empty-and-minimal", preset.Args{"formats": "png,bmp,zip"})
+ if err != nil {
+ t.Fatalf("the preset refused three formats: %v", err)
+ }
+ second, err := preset.Expand("empty-and-minimal", preset.Args{"formats": "zip,png,bmp"})
+ if err != nil {
+ t.Fatalf("the preset refused the same three in another order: %v", err)
+ }
+ if !bytes.Equal(first.Source, second.Source) {
+ t.Errorf("the same formats in two orders built two recipes.\n--- png,bmp,zip ---\n%s\n--- zip,png,bmp ---\n%s",
+ first.Source, second.Source)
+ }
+
+ // And the order they come out in is the registry's, rather than merely
+ // being the same both times - two runs agreeing on a wrong order would
+ // satisfy the check above and still put bmp after zip.
+ doc, err := recipe.Parse(first.Source, "empty-and-minimal.yaml")
+ if err != nil {
+ t.Fatalf("the preset wrote a recipe this build cannot read: %v", err)
+ }
+ seen := make([]string, 0, len(doc.Targets))
+ for _, target := range doc.Targets {
+ seen = append(seen, target.Format)
+ }
+ if want := []string{"bmp", "png", "zip"}; strings.Join(seen, ",") != strings.Join(want, ",") {
+ t.Errorf("the set is laid out as %v and the registry names them %v", seen, want)
+ }
+}
+
+// A set that came out with only one of its halves says so.
+//
+// Untouchable rule 6. "--formats zip" is a sensible thing to ask for and no
+// archive has a legal empty form, so the set is all minimal and no empty - and a
+// preset named after both halves that ships one quietly is a promise it did not
+// keep.
+//
+// The second half of this guard is the one that stops it from passing for the
+// wrong reason: a preset that said this on EVERY run would also pass the first
+// half, and would be noise on the run that has both halves.
+func TestASetWithNoEmptyHalfSaysSoAndOneWithBothStaysQuiet(t *testing.T) {
+ quiet, err := preset.Expand("empty-and-minimal", preset.Args{"formats": "txt,zip"})
+ if err != nil {
+ t.Fatalf("the preset refused a set with both halves: %v", err)
+ }
+ for _, note := range quiet.Notes() {
+ if strings.Contains(note, "no empty files") {
+ t.Errorf("a set that HAS empty files still says %q", note)
+ }
+ }
+
+ spoken, err := preset.Expand("empty-and-minimal", preset.Args{"formats": "zip"})
+ if err != nil {
+ t.Fatalf("the preset refused a set of one format: %v", err)
+ }
+ said := strings.Join(spoken.Notes(), "\n")
+ if !strings.Contains(said, "no empty files") {
+ t.Errorf("a set with no empty half says nothing about it. It said: %q", said)
+ }
+ // The sentence names the formats that CAN be empty, and it has to name them
+ // from the registry - a list written into the sentence would go stale green
+ // the day a third format reached nought bytes.
+ for _, id := range format.IDs() {
+ desc, err := format.Get(id)
+ if err != nil || desc.SmallestAccepted(format.Request{Label: true}) != 0 {
+ continue
+ }
+ if !strings.Contains(said, id) {
+ t.Errorf("%s reaches nought bytes and the note does not name it: %q", id, said)
+ }
+ }
+}
+
+// A list parameter refuses two spellings of one item.
+//
+// This is the 2026-08-05 collision one layer up, and it came back on 2026-09-22
+// in the shared list parser: "PNG,png" passed a duplicate check made on what was
+// typed, became two targets of one id, and surfaced as "target id minimal_png is
+// used twice" - a refusal about an id nobody had written, pointing at the recipe
+// rather than at the value.
+//
+// Asserted through the message rather than only through the failure, because
+// both spellings failing is not the point. WHICH refusal arrives is the point.
+func TestAListParameterRefusesTwoSpellingsOfOneItem(t *testing.T) {
+ _, err := preset.Expand("empty-and-minimal", preset.Args{"formats": "PNG,png"})
+ if err == nil {
+ t.Fatal("the preset built a set holding one format twice")
+ }
+ if !strings.Contains(err.Error(), "the same format") {
+ t.Errorf("the refusal is %q.\nIt has to be about the repeated value, not about the "+
+ "recipe the value produced", err.Error())
+ }
+}
+
+// No format is called by the word that means all of them.
+//
+// The formats parameter takes "all" as a keyword, so a format registered under
+// that id would be unreachable through the parameter that exists to reach it -
+// silently, because the keyword would simply win. Nothing else in the tree would
+// notice.
+//
+// Cheap, and it is the kind of collision that turns up years later with no clue
+// attached. See internal/preset/emptyandminimal.go, which names this guard.
+func TestNoFormatIsCalledByTheWordThatMeansAllOfThem(t *testing.T) {
+ ids := format.IDs()
+ if len(ids) == 0 {
+ t.Fatal("no format is registered, so this guard checked nothing")
+ }
+ for _, id := range ids {
+ if strings.EqualFold(id, "all") {
+ t.Errorf("a format is registered as %q, which is the word the empty-and-minimal "+
+ "formats parameter uses for every format - one of the two has to be renamed", id)
+ }
+ }
+}
diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go
index acddc7eb..3a5795f2 100644
--- a/internal/guard/parity_test.go
+++ b/internal/guard/parity_test.go
@@ -59,6 +59,12 @@ var reachableFromTheWindow = []string{
"preset:size-boundaries",
"preset:size-boundaries.limit",
"preset:size-boundaries.spread",
+ // The second preset and its one parameter. Pressed on the screen by
+ // TestThePresetScreenBuildsTheMinimalSetTheCommandLineBuilds, which types
+ // a list of formats into the box and compares the run with the one the
+ // command line makes from the same list.
+ "preset:empty-and-minimal",
+ "preset:empty-and-minimal.formats",
// The global flag this preset supplies a value for, drawn from
// preset.Global and pressed by TestThePresetScreenCanBuildTheSetInAnyFormat,
// which runs the set and reads the bytes back rather than looking at the
diff --git a/internal/guard/presetwindow_test.go b/internal/guard/presetwindow_test.go
index bb7b6733..8bf2415a 100644
--- a/internal/guard/presetwindow_test.go
+++ b/internal/guard/presetwindow_test.go
@@ -38,6 +38,108 @@ import (
// It returns that tab rather than the window: since 2026-08-11 the window holds
// every screen at once, and both work screens have a field called "output
// directory", so a lookup across the whole thing finds whichever comes first.
+// The preset screen builds the minimal set the command line builds, with the
+// formats typed into the box.
+//
+// This is what the two parity entries for empty-and-minimal stand on. The
+// screen draws the parameter from the same declaration the flag comes from, so
+// "it appears on both surfaces" is nearly free - and nearly free is the problem.
+// What is NOT free is that a list typed into a box reaches the engine as the
+// same list the flag carries, in the same order, producing the same bytes.
+//
+// Three formats rather than the default, because the default is the one value
+// that cannot tell a box that works from a box whose contents are dropped: with
+// "all" standing in either way, a screen that sent nothing would pass.
+func TestThePresetScreenBuildsTheMinimalSetTheCommandLineBuilds(t *testing.T) {
+ root := t.TempDir()
+ fromCLI := filepath.Join(root, "cli")
+ fromWindow := filepath.Join(root, "window")
+
+ const chosen = "png,txt,zip"
+
+ var out, errOut bytes.Buffer
+ if code := cli.Run(context.Background(), []string{
+ "generate", "--preset", "empty-and-minimal", "--formats", chosen, "--out", fromCLI,
+ }, &out, &errOut); code != cli.ExitOK {
+ t.Fatalf("the command line refused the preset: exit %d\n%s", code, errOut.String())
+ }
+
+ host, content := presetScreen(t)
+ choosePreset(t, content, "empty-and-minimal")
+ fill(t, content, text.FieldOutputDir(), fromWindow)
+ fill(t, content, text.SettingLabel("formats"), chosen)
+ press(t, content, "Generate")
+ waitForManifest(t, host, fromWindow)
+ join(host)
+
+ cliNames, windowNames := namesIn(t, fromCLI), namesIn(t, fromWindow)
+ if strings.Join(cliNames, " ") != strings.Join(windowNames, " ") {
+ t.Fatalf("the two surfaces produced different files.\n command line: %v\n window: %v",
+ cliNames, windowNames)
+ }
+
+ // png, txt and zip are three minimal files, and txt reaches nought bytes so
+ // it brings an empty one as well. Asserted rather than logged, because an
+ // equality between two empty sets proves nothing.
+ const wanted = 4
+ if len(cliNames) != wanted+1 {
+ t.Fatalf("the preset produced %d thing(s) and %d files plus a manifest was expected: %v",
+ len(cliNames), wanted, cliNames)
+ }
+
+ compared := 0
+ for _, name := range cliNames {
+ if name == "manifest.json" {
+ continue
+ }
+ compared++
+ a, err := os.ReadFile(filepath.Join(fromCLI, name))
+ if err != nil {
+ t.Fatalf("reading %s from the command line run: %v", name, err)
+ }
+ b, err := os.ReadFile(filepath.Join(fromWindow, name))
+ if err != nil {
+ t.Fatalf("reading %s from the window run: %v", name, err)
+ }
+ if !bytes.Equal(a, b) {
+ t.Errorf("%s differs between the surfaces: %d B from the command line, %d B from the window",
+ name, len(a), len(b))
+ }
+ }
+ if compared != wanted {
+ t.Fatalf("%d files were compared and %d were expected", compared, wanted)
+ }
+
+ // The record has to agree too, or two runs that produced the same bytes
+ // would still be described differently to whoever reads the manifest.
+ for _, want := range []string{`"id": "empty-and-minimal"`, `"formats": "` + chosen + `"`} {
+ if !strings.Contains(manifestText(t, fromWindow), want) {
+ t.Errorf("the window's manifest does not carry %s", want)
+ }
+ if !strings.Contains(manifestText(t, fromCLI), want) {
+ t.Errorf("the command line's manifest does not carry %s", want)
+ }
+ }
+}
+
+// choosePreset picks one preset on the screen by name.
+//
+// Explicitly, rather than leaning on the choice the screen opens with. That
+// choice is the first id in order, so it moved the day a preset sorting before
+// size-boundaries arrived - and six guards went red at once, every one of them
+// reporting that a field was not on the screen rather than that they had been
+// looking at a different preset. A guard that names what it is about cannot be
+// moved by the next preset to be written.
+func choosePreset(t *testing.T, content fyne.CanvasObject, id string) {
+ t.Helper()
+ control := controlUnder(content, text.FieldPreset())
+ picker, ok := control.(*parts.Chooser)
+ if !ok {
+ t.Fatalf("the preset field is %T rather than a list to choose from", control)
+ }
+ picker.SetSelected(id)
+}
+
func presetScreen(t *testing.T) (*fakeHost, fyne.CanvasObject) {
t.Helper()
host := newFakeHost(t)
@@ -142,6 +244,7 @@ func TestThePresetScreenAndTheCommandLineProduceTheSameRun(t *testing.T) {
}
host, content := presetScreen(t)
+ choosePreset(t, content, "size-boundaries")
fill(t, content, text.FieldOutputDir(), fromWindow)
fill(t, content, text.SettingLabel("limit"), "2mb")
press(t, content, "Generate")
@@ -228,6 +331,7 @@ func TestThePresetScreenAndTheCommandLineProduceTheSameRun(t *testing.T) {
func TestThePresetScreenSaysWhichNumbersWereOurs(t *testing.T) {
dir := t.TempDir()
host, content := presetScreen(t)
+ choosePreset(t, content, "size-boundaries")
fill(t, content, text.FieldOutputDir(), dir)
// limit left at its declared default, spread stated by hand.
diff --git a/internal/guard/refusalplacement_test.go b/internal/guard/refusalplacement_test.go
index deb30be7..79db7da6 100644
--- a/internal/guard/refusalplacement_test.go
+++ b/internal/guard/refusalplacement_test.go
@@ -82,6 +82,7 @@ func TestARefusalAboutTheSizeAppearsUnderIt(t *testing.T) {
// generate screen's refusals were moved on 2026-08-11.
func TestARefusalAboutAPresetSettingAppearsUnderIt(t *testing.T) {
content, w := screenInAWindow(t, text.TabPresets())
+ choosePreset(t, content, "size-boundaries")
fill(t, content, text.SettingLabel("limit"), "512")
press(t, content, "Preview")
diff --git a/internal/guard/regressiontable_test.go b/internal/guard/regressiontable_test.go
index 182fb1f2..85580e15 100644
--- a/internal/guard/regressiontable_test.go
+++ b/internal/guard/regressiontable_test.go
@@ -48,7 +48,6 @@ import (
// would quietly become the place drift hides.
var notYetJustified = []string{
"actionbar_test.go",
- "compose_test.go",
"darkmenus_test.go",
"doccomments_test.go",
"dropdown_test.go",
diff --git a/internal/guard/screenpixels_test.go b/internal/guard/screenpixels_test.go
index 3ba9be39..eb39e780 100644
--- a/internal/guard/screenpixels_test.go
+++ b/internal/guard/screenpixels_test.go
@@ -419,7 +419,11 @@ func screenScenes() []screenScene {
flipSwitch(t, s.canvas, s.tab, text.FieldLabel())
}},
{name: "preset", tab: text.TabPresets()},
+ // The refusal belongs to size-boundaries, so the preset is named rather
+ // than left to whichever one the screen opens with - that is the first
+ // id in order and it moved the day a preset sorting earlier arrived.
{name: "preset-refused", tab: text.TabPresets(), set: func(t *testing.T, s scene) {
+ menuUnder(t, s.tab, text.FieldPreset()).SetSelected("size-boundaries")
fillField(t, s.tab, text.SettingLabel("limit"), "512")
pressNamed(t, s.tab, text.ButtonPreview())
}},
@@ -436,9 +440,15 @@ func screenScenes() []screenScene {
// The list a preset DECLARES, drawn by the same machinery from the same
// kind of declaration as a format's own settings, and landing somewhere
// else on the form.
- {name: "preset-menu-setting", tab: text.TabPresets(), after: func(t *testing.T, s scene) {
- menuUnder(t, s.tab, text.SettingLabel("format")).Tapped(&fyne.PointEvent{})
- }},
+ {name: "preset-menu-setting", tab: text.TabPresets(),
+ set: func(t *testing.T, s scene) {
+ // The only preset that reads a global flag, so it is the only
+ // one with a menu among its settings.
+ menuUnder(t, s.tab, text.FieldPreset()).SetSelected("size-boundaries")
+ },
+ after: func(t *testing.T, s scene) {
+ menuUnder(t, s.tab, text.SettingLabel("format")).Tapped(&fyne.PointEvent{})
+ }},
// The recipe screen, which arrived on 2026-08-18. It has states neither
// of the others can be put into, and every one of them is here because a
diff --git a/internal/guard/settingslot_test.go b/internal/guard/settingslot_test.go
index c61fa8fc..01e5c42a 100644
--- a/internal/guard/settingslot_test.go
+++ b/internal/guard/settingslot_test.go
@@ -185,7 +185,7 @@ func TestEveryNameARefusalCanBeGivenTakesTheArticleThisRuleGivesIt(t *testing.T)
"entries": "an", "bit_depth": "a", "sample_rate": "a", "channels": "a",
"paragraphs": "a", "rows": "a", "columns": "a", "slides": "a",
"depth": "a", "colours": "a", "records": "a", "lines": "a",
- "damage": "a", "bytes": "a",
+ "damage": "a", "bytes": "a", "formats": "a",
// The batch screen's base section: the switch, and the two recipe
// keys behind it. The parameters under with. arrive as the
// preset's own names, which are above.
@@ -198,6 +198,7 @@ func TestEveryNameARefusalCanBeGivenTakesTheArticleThisRuleGivesIt(t *testing.T)
"Limit to test": "a", "One size": "a", "A range": "a",
"Rule being tested": "a", "Manifest file name": "a", "Preset": "a",
"Limit": "a", "Spread": "a", "Width": "a", "Height": "a", "Quality": "a",
+ "Formats": "a",
"Label in each file": "a",
"Start from a preset": "a", "Preset to build on": "a",
}
diff --git a/internal/guard/testdata/screens/preset-menu-setting.png b/internal/guard/testdata/screens/preset-menu-setting.png
index 4a571f32..10775fbc 100644
Binary files a/internal/guard/testdata/screens/preset-menu-setting.png and b/internal/guard/testdata/screens/preset-menu-setting.png differ
diff --git a/internal/guard/testdata/screens/preset-menu-setting.xml b/internal/guard/testdata/screens/preset-menu-setting.xml
index 40a0cb18..584c4706 100644
--- a/internal/guard/testdata/screens/preset-menu-setting.xml
+++ b/internal/guard/testdata/screens/preset-menu-setting.xml
@@ -72,18 +72,18 @@
-
-
-
+
+
+
-
+
size-boundaries
-
+
-
+
diff --git a/internal/guard/testdata/screens/preset-menu.png b/internal/guard/testdata/screens/preset-menu.png
index 6e345f1f..1c2fa78f 100644
Binary files a/internal/guard/testdata/screens/preset-menu.png and b/internal/guard/testdata/screens/preset-menu.png differ
diff --git a/internal/guard/testdata/screens/preset-menu.xml b/internal/guard/testdata/screens/preset-menu.xml
index 5816ab03..b3c6c87f 100644
--- a/internal/guard/testdata/screens/preset-menu.xml
+++ b/internal/guard/testdata/screens/preset-menu.xml
@@ -56,10 +56,10 @@
-
-
-
-
+
+
+
+
The question
@@ -72,28 +72,28 @@
-
-
-
+
+
+
-
- size-boundaries
+
+ empty-and-minimal
-
+
-
+
-
+
- Is a size limit enforced exactly where it is declared?
+ Does a file that is valid and as small as the format allows get through?
@@ -107,13 +107,13 @@
-
+
- off by one errors at the limit
+ a valid file turned away for being too small, where the check counts bytes instead of reading them
@@ -125,7 +125,7 @@
- MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass
+ an empty file that brings the reader down rather than being reported
@@ -137,69 +137,41 @@
- a limit enforced in the browser and not on the server
+ a picture one pixel wide that divides by zero on the way to a thumbnail
•
-
-
-
-
-
-
-
-
-
- Settings
-
-
-
-
- Limit
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 10mb
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ storage that reads nought bytes as a failed upload and keeps retrying
+ •
-
+
+
+
+
+
+
+
+
+ Settings
+
+
- Spread
-
+ Formats
+
@@ -213,7 +185,7 @@
- 1B,1kb,1mb
+ all
@@ -226,38 +198,11 @@
-
-
-
- Format
-
-
-
-
-
-
-
-
-
-
-
-
- pdf
-
-
-
-
-
-
-
-
-
-
-
+
@@ -359,11 +304,12 @@
-
-
-
-
- 7 files · 70.0 MB (73 400 320 B) · pdf · will go to /tfg/out
+
+
+
+
+ 26 files · 31.5 KB (32 214 B) · avif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff,
+ txt, wav, webp, xlsx, xml, zip · will go to /tfg/out
@@ -394,23 +340,35 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- size-boundaries
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ empty-and-minimal
+
+
+
+
+
+ size-boundaries
+
+
+
+
+
+
diff --git a/internal/guard/testdata/screens/preset-refused.png b/internal/guard/testdata/screens/preset-refused.png
index 489d0021..cc9a76e2 100644
Binary files a/internal/guard/testdata/screens/preset-refused.png and b/internal/guard/testdata/screens/preset-refused.png differ
diff --git a/internal/guard/testdata/screens/preset-refused.xml b/internal/guard/testdata/screens/preset-refused.xml
index 84d8893c..c36e282d 100644
--- a/internal/guard/testdata/screens/preset-refused.xml
+++ b/internal/guard/testdata/screens/preset-refused.xml
@@ -72,18 +72,18 @@
-
-
-
+
+
+
-
+
size-boundaries
-
+
-
+
diff --git a/internal/guard/testdata/screens/preset.png b/internal/guard/testdata/screens/preset.png
index 5a60d654..2d237ea3 100644
Binary files a/internal/guard/testdata/screens/preset.png and b/internal/guard/testdata/screens/preset.png differ
diff --git a/internal/guard/testdata/screens/preset.xml b/internal/guard/testdata/screens/preset.xml
index 787a1714..8b1b7a5a 100644
--- a/internal/guard/testdata/screens/preset.xml
+++ b/internal/guard/testdata/screens/preset.xml
@@ -56,10 +56,10 @@
-
-
-
-
+
+
+
+
The question
@@ -72,28 +72,28 @@
-
-
-
+
+
+
-
- size-boundaries
+
+ empty-and-minimal
-
+
-
+
-
+
- Is a size limit enforced exactly where it is declared?
+ Does a file that is valid and as small as the format allows get through?
@@ -107,13 +107,13 @@
-
+
- off by one errors at the limit
+ a valid file turned away for being too small, where the check counts bytes instead of reading them
@@ -125,7 +125,7 @@
- MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass
+ an empty file that brings the reader down rather than being reported
@@ -137,69 +137,41 @@
- a limit enforced in the browser and not on the server
+ a picture one pixel wide that divides by zero on the way to a thumbnail
•
-
-
-
-
-
-
-
-
-
- Settings
-
-
-
-
- Limit
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 10mb
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ storage that reads nought bytes as a failed upload and keeps retrying
+ •
-
+
+
+
+
+
+
+
+
+ Settings
+
+
- Spread
-
+ Formats
+
@@ -213,7 +185,7 @@
- 1B,1kb,1mb
+ all
@@ -226,38 +198,11 @@
-
-
-
- Format
-
-
-
-
-
-
-
-
-
-
-
-
- pdf
-
-
-
-
-
-
-
-
-
-
-
+
@@ -359,11 +304,12 @@
-
-
-
-
- 7 files · 70.0 MB (73 400 320 B) · pdf · will go to /tfg/out
+
+
+
+
+ 26 files · 31.5 KB (32 214 B) · avif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff,
+ txt, wav, webp, xlsx, xml, zip · will go to /tfg/out
diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.png b/internal/guard/testdata/screens/recipe-on-a-preset.png
index 77498223..0abb3d32 100644
Binary files a/internal/guard/testdata/screens/recipe-on-a-preset.png and b/internal/guard/testdata/screens/recipe-on-a-preset.png differ
diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.xml b/internal/guard/testdata/screens/recipe-on-a-preset.xml
index a643567d..a635275d 100644
--- a/internal/guard/testdata/screens/recipe-on-a-preset.xml
+++ b/internal/guard/testdata/screens/recipe-on-a-preset.xml
@@ -32,8 +32,8 @@
-
-
+
+
@@ -56,11 +56,11 @@
-
-
-
-
-
+
+
+
+
+
Build on a preset
@@ -101,67 +101,27 @@
-
-
-
+
+
+
-
- size-boundaries
+
+ empty-and-minimal
-
+
-
+
-
-
-
- Limit
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 10mb
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
- Spread
-
+ Formats
+
@@ -175,7 +135,7 @@
- 1B,1kb,1mb
+ all
@@ -188,38 +148,11 @@
-
-
-
- Format
-
-
-
-
-
-
-
-
-
-
-
-
- pdf
-
-
-
-
-
-
-
-
-
-
-
+
@@ -490,7 +423,7 @@
-
+
@@ -622,8 +555,8 @@
-
-
+
+
diff --git a/internal/preset/build.go b/internal/preset/build.go
new file mode 100644
index 00000000..420ed9b3
--- /dev/null
+++ b/internal/preset/build.go
@@ -0,0 +1,173 @@
+package preset
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/format"
+ "github.com/donislawdev/TestingFilesGenerator/internal/recipe"
+)
+
+// The machinery every preset shares, so that a preset file holds its question
+// and its plan and nothing else.
+//
+// It exists because of what the second preset showed. size-boundaries carried
+// its own list parser, its own character check and its own YAML writer, and
+// writing empty-and-minimal beside it would have copied all three - with the
+// fourth and fifth copies already named in the queue, because upload-validation
+// takes two lists and text-encoding takes a third. A problem that comes back is
+// a missing primitive rather than a missing tidy-up.
+
+// commaList is the one parser behind every list a preset takes.
+//
+// Lists are written as one scalar with commas - "1B,1kb,1mb", "jpg,png,pdf" -
+// and that is a decision rather than a habit: a preset parameter is a
+// map[string]string all the way through, so the same value reads the same way
+// as a flag, under "with:" in a recipe and in a field on a screen. A second
+// spelling for one value is where eject and extends would drift apart. See
+// docs/EXTENDS-WITH-2026-09-22.md section 2.2 e.
+type commaList struct {
+ // preset and param name the setting a refusal is about, so the message
+ // carries the name both surfaces use - the flag without its dashes and the
+ // label on the field.
+ preset, param string
+ // empty is the reason for a list that named nothing at all.
+ empty string
+ // check judges one item and answers with why it is not allowed, or "" when
+ // it is. It receives the item trimmed, never blank.
+ check func(item string) string
+ // same is what makes two items one, for the duplicate that would otherwise
+ // reach the recipe as two targets of one id. Nil compares the items
+ // themselves. size-boundaries compares byte counts instead, so that 1024
+ // and 1kb collide - found by fuzzing on 2026-08-05, where the collision
+ // surfaced as a recipe the parser refused, complaining about target ids
+ // nobody typed.
+ same func(item string) string
+ // keep is what to store when an item is accepted. Nil keeps the item as it
+ // was trimmed.
+ keep func(item string) string
+ // duplicate words the refusal for an item the list is already holding,
+ // given the item it repeats.
+ //
+ // A field rather than one sentence written here, because the general
+ // sentence is worse than the one it would replace. A list of distances says
+ // "the same distance, and each one names one file either side of the
+ // limit", a list of formats says something else, and a primitive that
+ // flattens both into "the same item" makes the message vaguer for everyone
+ // in order to save a line. The text rules in CLAUDE.md ask for the name the
+ // reader sees, not the name the code uses.
+ duplicate func(first string) string
+}
+
+// refuse is the shape a bad value in a preset parameter takes.
+//
+// The type the format registry raises for a value outside its declaration,
+// rather than a plain error, and that is a repair rather than a preference. A
+// plain error falls through the classifier to RUNTIME, so "--spread notasize"
+// told CI this program had a bug instead of saying the value was wrong -
+// measured on 2026-08-05, exit 1.
+func (l commaList) refuse(value, reason string) error {
+ return &format.PropertyValueError{
+ Format: l.preset, Key: l.param, Value: value, Reason: reason,
+ }
+}
+
+// parse splits the value and judges every item of it.
+//
+// The order of the three steps is the whole of this function, and it was wrong
+// once. Normalising has to happen BEFORE two items are compared, because being
+// the same is a property of the value rather than of the typing: "PNG,png"
+// passed a check made on what was typed, then became two identical ids and
+// surfaced as "target id minimal_png is used twice" - a refusal about an id
+// nobody had written. Measured 2026-09-22, and it is the same shape as the
+// collision fuzzing found in the spread on 2026-08-05.
+func (l commaList) parse(raw string) ([]string, error) {
+ var out []string
+ seen := map[string]string{}
+ for _, piece := range strings.Split(raw, ",") {
+ typed := strings.TrimSpace(piece)
+ if typed == "" {
+ continue
+ }
+ // Judged as it was typed, so the message quotes what the reader can see
+ // in their own command line.
+ if bad := l.check(typed); bad != "" {
+ return nil, l.refuse(typed, bad)
+ }
+ item := typed
+ if l.keep != nil {
+ item = l.keep(typed)
+ }
+ key := item
+ if l.same != nil {
+ key = l.same(item)
+ }
+ if first, repeated := seen[key]; repeated {
+ return nil, l.refuse(typed, l.duplicate(first))
+ }
+ seen[key] = typed
+ out = append(out, item)
+ }
+ if len(out) == 0 {
+ return nil, l.refuse(raw, l.empty)
+ }
+ return out, nil
+}
+
+// knownFormat is the check for a list whose items name formats.
+//
+// The registry answers rather than a list written here, which is the rule
+// pulled from CLAUDE.md the hard way: a list copied by hand goes stale green.
+// The refusal names what this build has, because a person who typed heic has no
+// other way to find out what it does have.
+func knownFormat(item string) string {
+ if _, err := format.Get(strings.ToLower(item)); err != nil {
+ return fmt.Sprintf("this build has no format called that. It has: %s",
+ strings.Join(format.IDs(), ", "))
+ }
+ return ""
+}
+
+// lower is the keep for a list of names the registry spells in lower case.
+func lower(item string) string { return strings.ToLower(item) }
+
+// plan is the set a preset lays out, ready to be written.
+//
+// A preset describes targets and this turns them into source. PR5 asks for
+// source rather than a structure, so that what eject prints and what a run
+// consumes are the same bytes and cannot drift apart.
+type plan struct {
+ // preset and question go in the header, so an ejected recipe says where it
+ // came from and what it was for.
+ preset, question string
+ targets []recipe.TargetDraft
+}
+
+// source writes the recipe.
+//
+// recipe.Compose marshals with the YAML library rather than printing lines,
+// and that is the whole reason this exists. The hand written writer in
+// sizeboundaries.go carries a comment saying it is safe because every value is
+// one the package built itself - and that comment was wrong until 2026-08-05,
+// when fuzzing found "1\rB" reaching the document raw through a value the
+// caller had typed. Compose owns the quoting, and it owns the shape of the
+// document, so a key added to the schema is added in one place rather than in
+// every preset.
+//
+// The header is written here rather than composed, because a comment is not
+// part of the document and a marshaller has nowhere to put one. Every byte of
+// it is this package's own text - an id and a question, both constants - so
+// there is nothing here for a caller's typing to reach.
+func (p plan) source() ([]byte, error) {
+ body, err := recipe.Compose(recipe.Document{Targets: p.targets})
+ if err != nil {
+ return nil, err
+ }
+ var b strings.Builder
+ fmt.Fprintf(&b, "# Generated by: tfg preset eject %s\n", p.preset)
+ fmt.Fprintf(&b, "# %s\n", p.question)
+ b.WriteString("#\n")
+ b.WriteString("# Edit it, commit it, it is an ordinary recipe from here on.\n\n")
+ b.Write(body)
+ return []byte(b.String()), nil
+}
diff --git a/internal/preset/emptyandminimal.go b/internal/preset/emptyandminimal.go
new file mode 100644
index 00000000..db2d11bc
--- /dev/null
+++ b/internal/preset/emptyandminimal.go
@@ -0,0 +1,311 @@
+package preset
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/format"
+ "github.com/donislawdev/TestingFilesGenerator/internal/recipe"
+)
+
+const (
+ minimalID = "empty-and-minimal"
+ // everyFormat is what the formats parameter says when it means all of them.
+ //
+ // A word rather than the list written out, because the list is a property of
+ // the build and this declaration is read at init, before the format registry
+ // has necessarily finished filling. Global() above carries the same note for
+ // the same reason. It also means the default stays one short word on a
+ // screen instead of a hundred characters of ids.
+ everyFormat = "all"
+
+ minimalGroup = "minimal"
+ emptyGroup = "empty"
+
+ // minimalQuestion is announced by the preset AND written into the header of
+ // an ejected recipe. Named once rather than typed twice, because two copies
+ // of one sentence are how the recipe and the list drift apart.
+ minimalQuestion = "Does a file that is valid and as small as the format allows get through?"
+)
+
+func init() {
+ Register(Preset{
+ ID: minimalID,
+ Title: "Empty and minimal",
+ Question: minimalQuestion,
+
+ Parameters: []format.Property{
+ {
+ Name: "formats", Kind: format.PropertyText,
+ Shape: "format ids separated by commas, or all",
+ Default: everyFormat,
+ Detail: "Which formats the set is built from. Leave it at all for every format " +
+ "this build has, or name the ones your system accepts.",
+ },
+ },
+
+ Requires: []string{"MVP"},
+ Catches: []string{
+ "a valid file turned away for being too small, where the check counts bytes instead of reading them",
+ "an empty file that brings the reader down rather than being reported",
+ "a picture one pixel wide that divides by zero on the way to a thumbnail",
+ "storage that reads nought bytes as a failed upload and keeps retrying",
+ },
+
+ Says: saidAboutTheMinimalSet,
+ Expand: expandEmptyAndMinimal,
+ })
+}
+
+// formatsList is how the formats parameter is written.
+var formatsList = commaList{
+ preset: minimalID,
+ param: "formats",
+ empty: "no formats were given, so there is nothing to build the set from",
+ check: checkSetFormat,
+ keep: lower,
+ duplicate: repeatedFormat,
+}
+
+func repeatedFormat(first string) string {
+ return fmt.Sprintf(
+ "it is the same format as %q and the set would hold that file twice. Every format appears once, because each one stands for one path through your reader",
+ first)
+}
+
+// checkSetFormat lets the keyword through and asks the registry about the rest.
+//
+// Whether the keyword is allowed to stand BESIDE a named format is decided
+// after the list is parsed rather than here, because here the answer would be
+// wrong for "all," - a trailing comma leaves one item, the keyword is alone,
+// and a refusal saying it cannot stand beside a named format would be about a
+// format nobody wrote.
+func checkSetFormat(item string) string {
+ if strings.EqualFold(item, everyFormat) {
+ return ""
+ }
+ return knownFormat(item)
+}
+
+// chosenFormats is the formats this set covers, in registry order.
+//
+// Registry order rather than the order somebody typed, and the registry is
+// walked here rather than the typing, which is the difference between the
+// sentence being true and merely being written down. It was written down and
+// false until 2026-09-22: this loop ran over the ids as they arrived, so
+// "--formats png,zip" and "--formats zip,png" asked for one set and produced
+// two - different recipe text, a different recipe_hash in the manifest, and the
+// files listed the other way round. The bytes of the files themselves never
+// moved, because a seed comes from the id of a target rather than from its
+// place in the list. Found by review, not by a guard, and there is one now.
+//
+// Why it matters at all: the manifest answers "did this record come from that
+// recipe", and two people asking for the same thing have to be able to compare
+// their answers. SortChoices holds the same rule for a closed set of values one
+// level down.
+func chosenFormats(raw string) ([]format.Descriptor, error) {
+ ids, err := formatsList.parse(raw)
+ if err != nil {
+ return nil, err
+ }
+ ids, err = spelledOut(ids)
+ if err != nil {
+ return nil, err
+ }
+ wanted := make(map[string]bool, len(ids))
+ for _, id := range ids {
+ wanted[id] = true
+ }
+
+ // The keyword shadows a format of the same name, and no format is called
+ // all - TestNoFormatIsCalledByTheWordThatMeansAllOfThem holds that, so the
+ // shadow cannot appear without something going red.
+ out := make([]format.Descriptor, 0, len(ids))
+ for _, id := range format.IDs() {
+ if !wanted[id] {
+ continue
+ }
+ desc, err := format.Get(id)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, desc)
+ }
+ return out, nil
+}
+
+// spelledOut turns the keyword into the list of ids it stands for, and refuses
+// it standing beside a named format.
+//
+// Its own function rather than a branch inside chosenFormats, because the two
+// questions are separate: this one is about what the words mean, the one above
+// is about what the registry has. Keeping them apart also keeps each of them
+// two levels deep instead of one of them three, which is the shape the crowding
+// gate asks for and the reason it asks.
+func spelledOut(ids []string) ([]string, error) {
+ if len(ids) == 1 && ids[0] == everyFormat {
+ return format.IDs(), nil
+ }
+ for _, id := range ids {
+ if id == everyFormat {
+ return nil, formatsList.refuse(everyFormat, fmt.Sprintf(
+ "%q already means every format, so it cannot stand beside a named one. Write it on its own, or name only the formats you want",
+ everyFormat))
+ }
+ }
+ return ids, nil
+}
+
+// minimalFile is one file of the set.
+type minimalFile struct {
+ desc format.Descriptor
+ size int64
+ // empty marks the file that is nought bytes, which is the half of this set
+ // nobody can promise an answer for.
+ empty bool
+}
+
+func (f minimalFile) id() string {
+ if f.empty {
+ return emptyGroup + "_" + f.desc.ID
+ }
+ return minimalGroup + "_" + f.desc.ID
+}
+
+func (f minimalFile) name() string {
+ if f.empty {
+ return emptyGroup + f.desc.Extension
+ }
+ return minimalGroup + f.desc.Extension
+}
+
+// draft is this file as a target of the recipe.
+//
+// A method rather than a branch inside the loop that builds them, because a
+// file of this set knows which half it belongs to and the loop does not need
+// to ask. id and name above it are here for the same reason.
+func (f minimalFile) draft() recipe.TargetDraft {
+ if f.empty {
+ return recipe.TargetDraft{
+ ID: f.id(), Format: f.desc.ID, Count: "1",
+ Size: strconv.FormatInt(f.size, 10), Name: f.name(), Group: emptyGroup,
+ // Unspecified rather than accept, and the reason says which rule is
+ // in play rather than what anybody did with it. A file of nought
+ // bytes is legal and what a system should do with it is its own
+ // decision - storage keeps it, an upload form usually turns it
+ // away, and both are defensible.
+ Expected: "unspecified", ExpectedReason: "size_zero",
+ }
+ }
+ return recipe.TargetDraft{
+ ID: f.id(), Format: f.desc.ID, Count: "1",
+ Size: strconv.FormatInt(f.size, 10), Name: f.name(), Group: minimalGroup,
+ Expected: "accept",
+ }
+}
+
+// layOut is the set: the smallest legal file of every format, then the empty
+// ones.
+//
+// The two halves are two groups rather than one, because they carry two
+// different expectations and only one of them can be stated with any
+// confidence. A file that is valid should be accepted, and that is a positive
+// control - if those fail, every refusal the rest of the tool reports means
+// nothing. A file of nought bytes is another matter: it is legal, and what a
+// system ought to do with it is a policy its owner decides. MF5 and untouchable
+// rule 5 both say we do not invent that answer.
+//
+// Which is why txt and md appear twice. Their smallest legal file IS nought
+// bytes, so one entry each would mean either dropping two formats out of the
+// positive control or handing two empty files an expectation nobody can back.
+// A second file of one byte costs two bytes and keeps both halves honest.
+func layOut(descs []format.Descriptor) []minimalFile {
+ minimal := make([]minimalFile, 0, len(descs))
+ var empty []minimalFile
+ for _, d := range descs {
+ floor := smallest(d)
+ if floor > 0 {
+ minimal = append(minimal, minimalFile{desc: d, size: floor})
+ continue
+ }
+ minimal = append(minimal, minimalFile{desc: d, size: 1})
+ empty = append(empty, minimalFile{desc: d, size: 0, empty: true})
+ }
+ return append(minimal, empty...)
+}
+
+// smallest is the smallest size this build will actually take for the format.
+//
+// The label is on, because it is on unless somebody passes --clean, and the
+// number "tfg formats" prints under MINIMUM is this one. The preset and the
+// table have to agree: a set built from a number the table does not show is a
+// set nobody can check by hand.
+//
+// MinBytes beside it is the structural floor with no label, and it is NOT the
+// same number - docx, pdf, targz, wav and zip all differ, measured 2026-09-22.
+// Asking for MinBytes is refused.
+func smallest(d format.Descriptor) int64 {
+ return d.SmallestAccepted(format.Request{Label: true})
+}
+
+// saidAboutTheMinimalSet says when the set came out with only one of its halves.
+//
+// A preset named after both halves that quietly ships one is a promise it did
+// not keep, and the whole of untouchable rule 6 is about that kind of silence.
+// It happens for a real choice rather than a strange one: "--formats zip" is a
+// sensible thing to ask for, and no archive has a legal empty form.
+//
+// Nothing is said about the other direction. A set that is ALL empty files
+// cannot happen, because every format has a smallest legal file and this set
+// always holds it.
+func saidAboutTheMinimalSet(args Args) []string {
+ descs, err := chosenFormats(args["formats"])
+ if err != nil {
+ // Expand is about to refuse these same values with a message that names
+ // the item. A sentence here would be a second opinion on one question.
+ return nil
+ }
+ for _, d := range descs {
+ if smallest(d) == 0 {
+ return nil
+ }
+ }
+ return []string{fmt.Sprintf(
+ "no format in this set has a legal empty form, so the set holds no empty files - only the smallest valid one of each. The formats that go down to nought bytes in this build: %s.",
+ strings.Join(zeroCapable(), ", "))}
+}
+
+// zeroCapable is every format whose smallest legal file is nought bytes.
+//
+// Asked of the registry rather than written down. Two formats answer today and
+// a third would join them without this sentence noticing, which is the failure
+// CLAUDE.md calls a list copied by hand going stale green.
+func zeroCapable() []string {
+ var out []string
+ for _, id := range format.IDs() {
+ desc, err := format.Get(id)
+ if err != nil {
+ continue
+ }
+ if smallest(desc) == 0 {
+ out = append(out, id)
+ }
+ }
+ return out
+}
+
+func expandEmptyAndMinimal(args Args) ([]byte, error) {
+ descs, err := chosenFormats(args["formats"])
+ if err != nil {
+ return nil, err
+ }
+
+ files := layOut(descs)
+ targets := make([]recipe.TargetDraft, 0, len(files))
+ for _, f := range files {
+ targets = append(targets, f.draft())
+ }
+
+ return plan{preset: minimalID, question: minimalQuestion, targets: targets}.source()
+}
diff --git a/internal/preset/expansion.go b/internal/preset/expansion.go
index 677db1cb..7be59795 100644
--- a/internal/preset/expansion.go
+++ b/internal/preset/expansion.go
@@ -61,5 +61,10 @@ func (e *Expansion) Notes() []string {
out = append(out, said)
}
}
+ // What those values then laid out, after what we invented, because a
+ // sentence about the set reads as the consequence of the numbers above it.
+ if e.Preset.Says != nil {
+ out = append(out, e.Preset.Says(e.Settled)...)
+ }
return out
}
diff --git a/internal/preset/preset.go b/internal/preset/preset.go
index ac6c3054..6e978f76 100644
--- a/internal/preset/preset.go
+++ b/internal/preset/preset.go
@@ -73,6 +73,27 @@ type Preset struct {
// runs and says which number it invented.
SaidWhenDefaulted map[string]string
+ // Says is what to say out loud about the set that was actually laid out,
+ // given the values it was settled on.
+ //
+ // SaidWhenDefaulted above speaks about a value nobody gave us. This speaks
+ // about what those values then produced, which is a different question and
+ // had no channel until 2026-09-22. The case that needed one: a preset
+ // called empty-and-minimal, asked for formats that have no legal empty
+ // form, builds the minimal half and no empty half - and a preset named
+ // after both halves that quietly ships one is a promise it did not keep.
+ // Untouchable rule 6 is about exactly that silence.
+ //
+ // It is handed the settled parameters and has to be pure, because every
+ // surface calls Notes when it happens to need it rather than once. Adding a
+ // sentence here reaches the command line, the window, the JSON report and
+ // eject without any of them changing, because all seven consumers already
+ // go through Expansion.Notes - which is what keeps D1 true without a second
+ // wiring to remember.
+ //
+ // Nil for a preset whose set is the same shape whatever it is given.
+ Says func(Args) []string
+
// Expand builds the recipe. It returns source rather than a structure, so
// what a run consumes is what eject prints.
Expand func(Args) ([]byte, error)
diff --git a/internal/preset/sizeboundaries.go b/internal/preset/sizeboundaries.go
index 26dd155d..0b2ca88f 100644
--- a/internal/preset/sizeboundaries.go
+++ b/internal/preset/sizeboundaries.go
@@ -2,10 +2,12 @@ package preset
import (
"fmt"
+ "strconv"
"strings"
"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
+ "github.com/donislawdev/TestingFilesGenerator/internal/recipe"
)
const (
@@ -13,13 +15,18 @@ const (
defaultLimitText = "10mb"
defaultSpreadText = "1B,1kb,1mb"
defaultFormat = "pdf"
+
+ // boundariesQuestion is announced by the preset AND written into the header
+ // of an ejected recipe. Named once rather than typed twice: the two copies
+ // had already been sitting in this file since it was written.
+ boundariesQuestion = "Is a size limit enforced exactly where it is declared?"
)
func init() {
Register(Preset{
ID: boundariesID,
Title: "Size boundaries",
- Question: "Is a size limit enforced exactly where it is declared?",
+ Question: boundariesQuestion,
Parameters: []format.Property{
{
@@ -59,18 +66,66 @@ type offset struct {
bytes int64
}
-// badSpread is a value the spread parameter does not accept.
+// spreadList is how the distances either side of the limit are written.
//
-// The same type the format registry raises for a value outside its declaration,
-// rather than a plain error, and that is a repair rather than a preference. A
-// plain error falls through the classifier to RUNTIME, so "--spread notasize"
-// told CI this program had a bug instead of saying the value was wrong -
-// measured on 2026-08-05, exit 1. The same class as "--set width=abc", which
-// was fixed for the same reason two days earlier.
+// The shared parser does the splitting, the duplicate and the refusal, and this
+// says what one distance has to look like. Two equal distances make two steps
+// of the set that are the same file twice and collide on the id built from the
+// distance - found by fuzzing on 2026-08-05, where the collision surfaced as a
+// recipe the parser refused, complaining about target ids nobody typed.
+// Compared as bytes rather than as text, so 1024 and 1kb are caught as well as
+// 1B and 1b.
+var spreadList = commaList{
+ preset: boundariesID,
+ param: "spread",
+ empty: "no distances were given, so there is nothing either side of the limit",
+ check: checkDistance,
+ same: sizeKey,
+ keep: lower,
+ duplicate: repeatedDistance,
+}
+
+func repeatedDistance(first string) string {
+ return fmt.Sprintf(
+ "it is the same distance as %q and the set would hold that step twice. Every distance has to be different, because each one names one file either side of the limit",
+ first)
+}
+
+// badSpread is a value the spread parameter does not accept.
func badSpread(value, reason string) error {
- return &format.PropertyValueError{
- Format: boundariesID, Key: "spread", Value: value, Reason: reason,
+ return spreadList.refuse(value, reason)
+}
+
+// checkDistance answers why a piece of the spread is not a distance.
+func checkDistance(piece string) string {
+ // The text of a distance becomes the id of a target and the name of a
+ // file, so it has to be made of what a size is made of and nothing else.
+ // Found by fuzzing on 2026-08-05: "1\rB" parses as one byte, because the
+ // size parser trims the ends and this carriage return is in the middle -
+ // and the character then reached the recipe source raw and broke the
+ // document.
+ if bad := firstUnusable(piece); bad != "" {
+ return fmt.Sprintf(
+ "it holds %s, and a distance is written with digits, letters and a dot - such as 1kb, 512 or 1.5mb. Its text becomes the name of a file", bad)
+ }
+ n, err := core.ParseSize(piece)
+ if err != nil {
+ return err.Error()
+ }
+ if n <= 0 {
+ return "a distance from the limit has to be more than nothing"
+ }
+ return ""
+}
+
+// sizeKey is what makes two distances the same one, for the duplicate check.
+// Anything checkDistance has passed parses here, so a failure cannot arrive.
+func sizeKey(piece string) string {
+ n, err := core.ParseSize(piece)
+ if err != nil {
+ return piece
}
+ return fmt.Sprintf("%d", n)
}
// firstUnusable names the first character that cannot appear in a distance,
@@ -87,46 +142,20 @@ func firstUnusable(piece string) string {
}
func parseSpread(raw string) ([]offset, error) {
- var out []offset
- seen := map[int64]string{}
- for _, piece := range strings.Split(raw, ",") {
- piece = strings.TrimSpace(piece)
- if piece == "" {
- continue
- }
- // The text of a distance becomes the id of a target and the name of a
- // file, so it has to be made of what a size is made of and nothing
- // else. Found by fuzzing on 2026-08-05: "1\rB" parses as one byte,
- // because the size parser trims the ends and this carriage return is in
- // the middle - and the character then reached the recipe source raw and
- // broke the document. The comment on render() claimed no value there
- // needed quoting. That was true of every value except this one.
- if bad := firstUnusable(piece); bad != "" {
- return nil, badSpread(piece, fmt.Sprintf(
- "it holds %s, and a distance is written with digits, letters and a dot - such as 1kb, 512 or 1.5mb. Its text becomes the name of a file", bad))
- }
+ pieces, err := spreadList.parse(raw)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]offset, 0, len(pieces))
+ for _, piece := range pieces {
+ // Parsed again rather than carried through the list, because the list
+ // deals in text for every preset and only this one wants the number.
+ // checkDistance has already refused anything that would fail here.
n, err := core.ParseSize(piece)
if err != nil {
return nil, badSpread(piece, err.Error())
}
- if n <= 0 {
- return nil, badSpread(piece, "a distance from the limit has to be more than nothing")
- }
- // Two equal distances make two steps of the set that are the same file
- // twice, and they collide on the id built from the distance. Found by
- // fuzzing on 2026-08-05: the collision surfaced as a recipe the parser
- // refused, complaining about target ids nobody typed. Compared as bytes
- // rather than as text, so 1024 and 1kb are caught as well as 1B and 1b.
- if first, repeated := seen[n]; repeated {
- return nil, badSpread(piece, fmt.Sprintf(
- "it is the same distance as %q and the set would hold that step twice. Every distance has to be different, because each one names one file either side of the limit",
- first))
- }
- seen[n] = piece
- out = append(out, offset{text: strings.ToLower(piece), bytes: n})
- }
- if len(out) == 0 {
- return nil, badSpread(raw, "no distances were given, so there is nothing either side of the limit")
+ out = append(out, offset{text: piece, bytes: n})
}
return out, nil
}
@@ -204,11 +233,15 @@ func expandSizeBoundaries(args Args) ([]byte, error) {
}
}
- plan := steps(limit, spread)
- if err := reachable(plan, desc, limit); err != nil {
+ set := steps(limit, spread)
+ if err := reachable(set, desc, limit); err != nil {
return nil, err
}
- return render(plan, desc, limitText), nil
+ return plan{
+ preset: boundariesID,
+ question: boundariesQuestion,
+ targets: draftsOfSteps(set, desc, limitText),
+ }.source()
}
// reachable refuses the whole set when any one file of it is out of reach.
@@ -261,47 +294,39 @@ func largest(plan []step, limit int64) int64 {
return deepest
}
-// render writes the recipe.
+// draftsOfSteps is the set as targets, ready for the composer.
//
-// Source rather than a structure, so eject prints what a run consumes.
+// This used to print the document itself, line by line, with a comment saying
+// that was safe because every value was one the package built itself. The
+// comment was wrong until 2026-08-05: the id carries the caller's own text, so
+// "1\rB" reached the document raw and broke it, because the size parser trims
+// the ends and that carriage return sat in the middle. Found by fuzzing rather
+// than by reading.
//
-// Nothing here is quoted, and that is safe because of where the values come
-// from rather than because writing YAML by hand is safe: a byte count, a format
-// id the registry knows, and an id built from the text of a distance.
-//
-// That last one is the one to watch, and it was wrong until 2026-08-05. This
-// comment used to say every value was one the package built itself, and the id
-// carries the caller's own text - so "1\rB" reached the document raw and broke
-// it, because the size parser trims the ends and that carriage return sat in
-// the middle. Found by fuzzing, not by reading. parseSpread now refuses any
-// character a size is not written with, which is what makes the sentence above
-// true rather than merely confident.
-func render(plan []step, desc format.Descriptor, limitText string) []byte {
- var b strings.Builder
- b.WriteString("# Generated by: tfg preset eject " + boundariesID + "\n")
- b.WriteString("# " + "Is a size limit enforced exactly where it is declared?" + "\n")
- b.WriteString("#\n")
- b.WriteString("# Edit it, commit it, it is an ordinary recipe from here on.\n\n")
- b.WriteString("version: 1\n")
- b.WriteString("targets:\n")
-
- for _, s := range plan {
- fmt.Fprintf(&b, " - id: %s\n", s.id)
- fmt.Fprintf(&b, " format: %s\n", desc.ID)
- fmt.Fprintf(&b, " count: 1\n")
- fmt.Fprintf(&b, " size: %d\n", s.size)
- // The id stays as it was. It derives the seed, so putting the limit in
- // it would move the bytes of every file in this set for a change that
- // is about telling two directories apart.
- fmt.Fprintf(&b, " name: %s_%s%s\n", limitText, s.id, desc.Extension)
- fmt.Fprintf(&b, " group: %s\n", boundariesID)
- if s.accept {
- b.WriteString(" expected: accept\n")
- continue
+// parseSpread refuses that character now, and this no longer writes YAML at
+// all - plan.source hands the values to the marshaller, which does the quoting
+// and owns the shape of the document. Two defences rather than one, and the
+// second one cannot be forgotten by the next preset.
+func draftsOfSteps(set []step, desc format.Descriptor, limitText string) []recipe.TargetDraft {
+ out := make([]recipe.TargetDraft, 0, len(set))
+ for _, s := range set {
+ draft := recipe.TargetDraft{
+ ID: s.id,
+ Format: desc.ID,
+ Count: "1",
+ Size: strconv.FormatInt(s.size, 10),
+ // The id stays as it was. It derives the seed, so putting the limit
+ // in it would move the bytes of every file in this set for a change
+ // that is about telling two directories apart.
+ Name: limitText + "_" + s.id + desc.Extension,
+ Group: boundariesID,
+ Expected: "accept",
}
- b.WriteString(" expected:\n")
- b.WriteString(" outcome: reject\n")
- b.WriteString(" reason: size_limit\n")
+ if !s.accept {
+ draft.Expected = "reject"
+ draft.ExpectedReason = "size_limit"
+ }
+ out = append(out, draft)
}
- return []byte(b.String())
+ return out
}
diff --git a/internal/recipe/compose.go b/internal/recipe/compose.go
index e5afb54b..c06a0b3d 100644
--- a/internal/recipe/compose.go
+++ b/internal/recipe/compose.go
@@ -2,6 +2,7 @@ package recipe
import (
"fmt"
+ "strconv"
"github.com/goccy/go-yaml"
@@ -138,7 +139,18 @@ func Compose(d Document) ([]byte, error) {
}
doc = append(doc, yaml.MapItem{Key: "targets", Value: targets})
- return yaml.Marshal(doc)
+ // Sequences are indented under their key, which is not the marshaller's
+ // default and is what every recipe in docs/RECIPE.md looks like.
+ //
+ // It matters because of what somebody does with a composed recipe next. The
+ // header of an ejected one says "edit it, commit it, it is an ordinary
+ // recipe from here on", so a target gets pasted in from the documents - and
+ // at the flat default, that paste sits at a different indent from the
+ // entries above it and the file stops parsing, with the error pointing at
+ // the line the person just added. Caught by
+ // TestARecipeBuildingOnAPresetGivesTheBytesOfTheEjectedOneWithItsTargetsAppended
+ // on 2026-09-22, which appends a target the way a person would.
+ return yaml.MarshalWithOptions(doc, yaml.IndentSequence(true))
}
// withSection is the preset's parameters, sorted for the reason properties
@@ -173,13 +185,19 @@ func targetEntry(t TargetDraft) yaml.MapSlice {
entry = append(entry, yaml.MapItem{Key: key, Value: value})
}
}
+ // The keys where a number belongs are written as a number. See bareNumber.
+ addNumber := func(key, value string) {
+ if value != "" {
+ entry = append(entry, yaml.MapItem{Key: key, Value: bareNumber(value)})
+ }
+ }
add("id", t.ID)
add("format", t.Format)
- add("count", t.Count)
- add("size", t.Size)
+ addNumber("count", t.Count)
+ addNumber("size", t.Size)
add("size-range", t.SizeRange)
- add("boundary", t.Boundary)
+ addNumber("boundary", t.Boundary)
add("name", t.Name)
add("group", t.Group)
@@ -204,10 +222,10 @@ func targetEntry(t TargetDraft) yaml.MapSlice {
one = append(one, yaml.MapItem{Key: "format", Value: c.Format})
}
if c.Count != "" {
- one = append(one, yaml.MapItem{Key: "count", Value: c.Count})
+ one = append(one, yaml.MapItem{Key: "count", Value: bareNumber(c.Count)})
}
if c.Size != "" {
- one = append(one, yaml.MapItem{Key: "size", Value: c.Size})
+ one = append(one, yaml.MapItem{Key: "size", Value: bareNumber(c.Size)})
}
inside = append(inside, one)
}
@@ -216,6 +234,31 @@ func targetEntry(t TargetDraft) yaml.MapSlice {
return entry
}
+// bareNumber is a value written the way a person writes it - 1024 rather than
+// "1024".
+//
+// Everything in a Document is text, because a screen holds text and Parse is
+// the one thing allowed to judge it. Marshalling that text straight gives
+// size: "1024" - correct YAML, read back as the same number, and not what this
+// project's own documents show. RECIPE.md writes count: 25 and size: 2mb, and a
+// recipe this program composed is a document somebody then edits and commits,
+// so it has to look like the ones they have already read. Noticed 2026-09-22,
+// when the first preset to compose its recipe rather than print it put
+// size: "74" in front of somebody.
+//
+// Only a plain decimal is turned, and only where a number belongs. A size may
+// be "2mb" and a name may be "007" - a name quietly becoming the number seven
+// would be this doing harm rather than tidying. The round trip through
+// FormatInt is what refuses "007", "+5" and " 5": they parse, and they are not
+// how the number is written.
+func bareNumber(value string) any {
+ n, err := strconv.ParseInt(value, 10, 64)
+ if err != nil || n < 0 || value != strconv.FormatInt(n, 10) {
+ return value
+ }
+ return n
+}
+
// expectationEntry writes the short form when there is no reason and the long
// one when there is, which is the same choice a person writing the file by hand
// makes. Nil when nothing was stated.
diff --git a/web/content/en/site.json b/web/content/en/site.json
index 351aa25b..432ca61c 100644
--- a/web/content/en/site.json
+++ b/web/content/en/site.json
@@ -95,6 +95,7 @@
"143": "Stopped by a signal, which is what a CI timeout looks like."
},
"presets": {
+ "empty-and-minimal": "Does a file that is valid and as small as the format allows get through?",
"size-boundaries": "Is a size limit enforced exactly where it is declared?"
},
"commands": {
diff --git a/web/content/pl/site.json b/web/content/pl/site.json
index 8328b64c..9bd1fc00 100644
--- a/web/content/pl/site.json
+++ b/web/content/pl/site.json
@@ -95,6 +95,7 @@
"143": "Zatrzymane sygnałem - tak wygląda przekroczony czas w CI."
},
"presets": {
+ "empty-and-minimal": "Czy plik poprawny i najmniejszy, na jaki format pozwala, przechodzi?",
"size-boundaries": "Czy limit rozmiaru działa dokładnie tam, gdzie jest zadeklarowany?"
},
"commands": {
diff --git a/web/public/docs/index.html b/web/public/docs/index.html
index b54fd41c..238dbe56 100644
--- a/web/public/docs/index.html
+++ b/web/public/docs/index.html
@@ -307,6 +307,10 @@ What is a preset?
you can edit it from there.