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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,28 @@ because it turns other people's test suites red.

### Added

- **A recipe can build on a preset.** Two keys the recipe reader used to
refuse as not built yet now work: `extends: preset:<id>` names the preset
and `with:` fills its parameters, written the way the flags take them
(`limit: 5mb`, `spread: 1B,1kb,1mb`, `format: png`). The preset's files
come first and the recipe's own `targets` are added after them, so the
file is the same run as `tfg preset eject` with the extra targets typed
under it, byte for byte, and shorter. A target whose `id` the preset
already uses is refused rather than replaced, `with` without `extends` is
refused, and `extends` names a preset and nothing else yet - not another
file. A recipe with `extends` and no `targets` of its own is legal, which
is how a preset run is committed to a repository. The manifest records the
preset under `run.preset` with the parameters left out listed as
`defaulted`, as a `--preset` run does, and `run.recipe_hash` is the hash
of the file as written. `tfg validate --json` carries the same `preset`
block. On the desktop window, the batch screen has a section "Build on a
preset": a switch, the preset, and its parameters drawn under it. A preset
flag beside a recipe file (`tfg generate r.yaml --limit 5mb`) is refused
with a sentence saying the value goes under `with`.
- **The batch screen's "Label in each file" switch starts on.** It started
off, while the single batch screen and a recipe file with no `defaults`
section both have the label on - so the same recipe run from that screen
gave different bytes. All three now agree.
- **Every control shows where the keyboard is and answers the pointer.** Every
place the keyboard can land - a box, a menu, a switch, a button, the
segmented switch, a word on the tab strip - draws the same 2 px ring when a
Expand Down
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,8 @@ tfg generate fixtures.yaml
| `seed` | the number that makes a run repeatable. Same seed, same bytes |
| `defaults.label` | write the self describing label inside each file. Default `true` |
| `targets` | the list of things to produce. See below |
| `extends` | a preset to build on, as `preset:size-boundaries`. Its files come first and your `targets` are added after them. See [Building on a preset](#building-on-a-preset) |
| `with` | the preset's parameters, written the way the flags take them: `limit: 5mb`, `spread: 1B,1kb,1mb`, `format: png`. One left out stands in from its default, and the manifest says so |
| `output.dir` | where the files and the manifest go. A relative path is read from the directory you run in, not from the one the recipe sits in |
| `output.manifest` | manifest file name. Default `manifest.json` |

Expand Down Expand Up @@ -483,11 +485,39 @@ A reason names **the rule in play**, not the verdict. That is why the same
reason can sit under either outcome - a file one byte under a limit is
`accept`, and the rule it is about is still `size_limit`.

### Building on a preset

A recipe can start from a preset's set and add its own files:

```yaml
version: 1
seed: 7
extends: preset:size-boundaries
with:
limit: 5mb
format: png
targets:
- id: our-legacy-format
format: tiff
count: 1
size: 3mb
```

This is the same run as `tfg preset eject size-boundaries --limit 5mb --format png`
with the extra target typed under it, byte for byte. The file is shorter, it
says which question the set answers, and the manifest records the preset under
`run.preset` with the parameters you left out listed as `defaulted`. A target
whose `id` the preset already uses is refused, never silently replaced. A
recipe with `extends` and no `targets` of its own is legal, and it is how a
preset run is committed to a repository. The `--limit` and other preset flags
do not apply beside a recipe file, and the recipe's `with` is where they go.

### Not built yet

These keys are recognised and **refused with a message saying so**, never
ignored quietly: `extends`, `with`, `policy`, `engine`, `defaults.fill`,
`fill` on a target, `mutations`, `output.split_threshold`.
ignored quietly: `policy`, `engine`, `defaults.fill`, `fill` on a target,
`output.split_threshold`. `extends` names a preset and nothing else yet, so a
recipe cannot build on another file.

## 📁 Formats in detail

Expand Down
8 changes: 6 additions & 2 deletions internal/cli/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ func generate(ctx context.Context, args []string, out, errOut io.Writer) int {

// targetsFromRecipe reads the recipe and settles what the flags override.
func targetsFromRecipe(path string, g *generateOpts, given map[string]bool, opt *engine.Options, errOut io.Writer) ([]engine.Target, int) {
rec, hash, code := loadRecipe(path, errOut)
read, hash, code := loadRecipe(path, errOut)
if code != ExitOK {
return nil, code
}
Expand All @@ -200,7 +200,11 @@ func targetsFromRecipe(path string, g *generateOpts, given map[string]bool, opt
return nil, ExitUsage
}

return targetsFromParsedRecipe(rec, hash, g, given, opt), ExitOK
// A file that builds on a preset is recorded and heard the way a --preset
// run is. Nil for a file that stands alone, and the field stays absent.
sayNotes(read.Notes(), errOut)
opt.Preset = record(read.Expansion)
return targetsFromParsedRecipe(read.Recipe, hash, g, given, opt), ExitOK
}

// targetsFromParsedRecipe settles what the flags take away from a recipe that
Expand Down
26 changes: 21 additions & 5 deletions internal/cli/preset.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,29 @@ import (
// which is the output contract and no business of an input concept. The drift
// this invites is watched behaviourally instead - a guard runs the same preset
// from both surfaces and compares the records they produce.
//
// Nil in, nil out: a recipe file that stands alone has no preset to record,
// and the manifest field is absent rather than empty for it.
func record(e *preset.Expansion) *manifest.Preset {
if e == nil {
return nil
}
return &manifest.Preset{
ID: e.Preset.ID,
Parameters: map[string]string(e.Settled),
Defaulted: e.Defaulted,
}
}

// sayNotes tells a person what a preset invented, on standard error, where
// a run's other asides go. One place for the three roads that say it, so
// the prefix cannot drift between them.
func sayNotes(notes []string, errOut io.Writer) {
for _, note := range notes {
fmt.Fprintf(errOut, "note: %s\n", note)
}
}

// budget is what a preset would produce, counted by the planner.
//
// Not a declared number beside the code. The one that used to sit in
Expand Down Expand Up @@ -270,9 +285,12 @@ func explainUndefinedFlag(fs *flag.FlagSet, args []string, errOut io.Writer) boo
if name == "" {
return false
}
// The second sentence names both roads, because since 2026-09-22 a
// recipe file can build on the preset too - and beside a file the flag
// does not exist either, the file's with section is where the value goes.
fmt.Fprintf(errOut,
"tfg: --%s is a parameter of the preset %s, so it only exists beside it. Add --preset %s, or drop --%s.\n",
name, owner, owner, name)
"tfg: --%s is a parameter of the preset %s, so it only exists beside it. Add --preset %s, put %s under with: in a recipe that extends it, or drop --%s.\n",
name, owner, owner, name, name)
return true
}

Expand Down Expand Up @@ -331,9 +349,7 @@ func targetsFromPreset(fs *flag.FlagSet, g *generateOpts, given map[string]bool,
return nil, classify(err)
}

for _, note := range expanded.Notes() {
fmt.Fprintf(errOut, "note: %s\n", note)
}
sayNotes(expanded.Notes(), errOut)
opt.Preset = record(expanded)
return targetsFromParsedRecipe(rec, hash, g, given, opt), ExitOK
}
11 changes: 6 additions & 5 deletions internal/cli/presetcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,11 @@ func describePreset(e *preset.Expansion, b budget, out io.Writer) {
fmt.Fprintf(out, " - %s\n", c)
}
}
fmt.Fprintf(out, "\nRun \"tfg preset eject %s\" for the recipe, or \"tfg generate --preset %s\" to produce the files.\n",
p.ID, p.ID)
// Three roads to the same set, and all three are named: the third
// arrived on 2026-09-22 and a setting nobody knows they can reach is a
// setting that is not there.
fmt.Fprintf(out, "\nRun \"tfg preset eject %s\" for the recipe, \"tfg generate --preset %s\" to produce the files, or write \"extends: preset:%s\" in a recipe of your own.\n",
p.ID, p.ID, p.ID)
}

func presetEject(args []string, out, errOut io.Writer) int {
Expand All @@ -268,9 +271,7 @@ Usage:
// The note goes to the error channel. The recipe is the data here, and a
// sentence about a number we chose has no business inside a file somebody
// is about to commit.
for _, note := range expanded.Notes() {
fmt.Fprintf(errOut, "note: %s\n", note)
}
sayNotes(expanded.Notes(), errOut)
if _, err := out.Write(expanded.Source); err != nil {
fmt.Fprintf(errOut, "tfg: cannot write the recipe: %s\n", describeError(err))
return ExitIO
Expand Down
53 changes: 39 additions & 14 deletions internal/cli/recipecmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,28 @@ import (

"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
"github.com/donislawdev/TestingFilesGenerator/internal/preset"
"github.com/donislawdev/TestingFilesGenerator/internal/recipe"
)

func loadRecipe(path string, errOut io.Writer) (*recipe.Recipe, string, int) {
// loadRecipe reads a recipe file for a run, through the door that knows
// presets: a file that builds on one comes back with the preset it built on,
// so the run can record which numbers were the preset's own.
//
// The hash is of the file as written, whether or not it builds on a preset.
// It answers the question a pipeline asks - was this manifest made from the
// recipe committed here - and the manifest's preset record, with the tool's
// version beside it, says the rest. The owner's decision of 2026-09-22, in
// docs/EXTENDS-WITH-2026-09-22.md section 2.4.
func loadRecipe(path string, errOut io.Writer) (*preset.Read, string, int) {
src, err := readRecipe(path)
if err != nil {
said, code := recipeReadFailure(path, err)
fmt.Fprintf(errOut, "tfg: %s\n", said)
return nil, "", code
}
rec, err := recipe.Parse(src, path)
read, err := preset.ReadRecipe(src, path)
if err != nil {
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
return nil, "", classify(err)
Expand All @@ -32,7 +43,7 @@ func loadRecipe(path string, errOut io.Writer) (*recipe.Recipe, string, int) {
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
return nil, "", classify(err)
}
return rec, hash, ExitOK
return read, hash, ExitOK
}

// validate runs the checks a run would run and writes nothing at all, so it
Expand Down Expand Up @@ -66,10 +77,11 @@ func validate(ctx context.Context, args []string, out, errOut io.Writer) int {
return ExitUsage
}

rec, hash, code := loadRecipeReporting(path, *asJSON, errOut)
read, hash, code := loadRecipeReporting(path, *asJSON, errOut)
if code != ExitOK {
return code
}
rec := read.Recipe

// The schema and the semantics both passed. Planning is what proves the
// rest: a size below the minimum of its format, a format nobody
Expand All @@ -85,18 +97,27 @@ func validate(ctx context.Context, args []string, out, errOut io.Writer) int {
return planningRefusal(err, path, *asJSON, errOut)
}

// A file that builds on a preset says so here the way the manifest will
// say it, and its notes go where they go on a run: to a person, on
// standard error, before the files exist as well as after.
sayNotes(read.Notes(), errOut)

if *asJSON {
return writeJSON(out, errOut, validateReport{
Recipe: path, Valid: true, RecipeHash: hash,
Targets: len(rec.Targets), Files: len(planned),
TotalBytes: engine.TotalBytes(planned),
Preset: record(read.Expansion),
Problems: []validateProblem{},
}, ExitOK)
}

fmt.Fprintf(out, "%s is valid: %s, %s, %s total\n%s\n",
path, core.Count(len(rec.Targets), "target", "targets"), core.Count(len(planned), "file", "files"),
core.ExactBytes(engine.TotalBytes(planned)), hash)
if read.Expansion != nil {
fmt.Fprintf(out, "built on preset %s\n", read.Expansion.Preset.ID)
}
return ExitOK
}

Expand Down Expand Up @@ -138,13 +159,17 @@ func planningOptions(rec *recipe.Recipe) engine.Options {
// rather than as one blob of prose, because RC7 already reports them all at
// once and a script should not have to split the message back apart.
type validateReport struct {
Recipe string `json:"recipe"`
Valid bool `json:"valid"`
RecipeHash string `json:"recipe_hash,omitempty"`
Targets int `json:"targets,omitempty"`
Files int `json:"files,omitempty"`
TotalBytes int64 `json:"total_bytes,omitempty"`
Problems []validateProblem `json:"problems"`
Recipe string `json:"recipe"`
Valid bool `json:"valid"`
RecipeHash string `json:"recipe_hash,omitempty"`
Targets int `json:"targets,omitempty"`
Files int `json:"files,omitempty"`
TotalBytes int64 `json:"total_bytes,omitempty"`
// Preset is the preset the recipe builds on, in the shape the manifest
// records it - id, settled parameters, and which of them stood in from
// their defaults. Absent when the recipe stands alone.
Preset *manifest.Preset `json:"preset,omitempty"`
Problems []validateProblem `json:"problems"`
}

// validateProblem carries the three parts every refusal in this tool has: what
Expand Down Expand Up @@ -212,7 +237,7 @@ func addressOf(err error) string {
// loadRecipeReporting is loadRecipe with the option of a machine readable
// refusal. A recipe with five problems has to arrive as five entries, not as
// one string a script would have to take apart.
func loadRecipeReporting(path string, asJSON bool, errOut io.Writer) (*recipe.Recipe, string, int) {
func loadRecipeReporting(path string, asJSON bool, errOut io.Writer) (*preset.Read, string, int) {
if !asJSON {
return loadRecipe(path, errOut)
}
Expand All @@ -222,7 +247,7 @@ func loadRecipeReporting(path string, asJSON bool, errOut io.Writer) (*recipe.Re
return nil, "", writeJSON(errOut, errOut, validateReport{Recipe: path, Valid: false,
Problems: []validateProblem{{What: said}}}, code)
}
rec, err := recipe.Parse(src, path)
read, err := preset.ReadRecipe(src, path)
if err != nil {
report := validateReport{Recipe: path, Valid: false, Problems: []validateProblem{}}
var invalid *recipe.ValidationError
Expand All @@ -240,7 +265,7 @@ func loadRecipeReporting(path string, asJSON bool, errOut io.Writer) (*recipe.Re
return nil, "", writeJSON(errOut, errOut, validateReport{Recipe: path, Valid: false,
Problems: []validateProblem{{What: err.Error()}}}, classify(err))
}
return rec, hash, ExitOK
return read, hash, ExitOK
}

// recipeCmd groups the operations that work on a recipe file itself rather
Expand Down
Loading
Loading