From 764b938bbba01e8eacc4331d386f34fb3342cf42 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Fri, 25 Sep 2026 13:43:38 +0200 Subject: [PATCH 1/6] preset: every file of a preset says what it is for A run from a preset writes manifest.instructions.md beside the manifest, named after it: the question the preset answers, how to read the four outcomes, and every file with its format, size, expected outcome and one or two sentences on why it is in the set. A run of files of one target is one entry. Written from the manifest alone, by one function the command line and the window share, so both leave the same directory behind. The sentences come from a new optional recipe key, purpose, carried to files[].purpose in the manifest. A plain recipe key rather than something a preset passes around the recipe, because an ejected preset has to produce what the preset does (PR5). It takes no part in the seed, so no generated byte moves. It does enter the canonical recipe, so the pinned eject sums and the recipe_hash of every run from a preset move once - the owner's decision. All 140 targets of the six presets have one. The name is reserved before any target can take it, instructions already in the directory refuse the run before its first file, and a name too long for a file system is refused on the manifest's box. Instructions that cannot be written are said and leave no claim behind, and the run stands. verify does not count them as extra, cleanup --with-manifest removes them first, and a manifest naming anything but a plain name beside it is not acted on. The window offers Open instructions beside Open manifest, from one type for both buttons, and the batch screen has a Purpose box among the manifest notes, which moved into a type of their own to keep the batch under its field ceiling. cli/generate.go and preset/uploadset.go were split by what their parts do, and the file ceiling came down to 401. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 30 +++ README.md | 4 + internal/audit/audit.go | 5 +- internal/cli/cleanup.go | 50 +++- internal/cli/generate.go | 105 +------- internal/cli/preset.go | 1 + internal/cli/record.go | 139 +++++++++++ internal/engine/engine.go | 19 +- internal/engine/errors.go | 8 + internal/engine/names.go | 13 + internal/engine/preflight.go | 43 ++++ internal/engine/record.go | 68 ++++++ internal/guard/codeshape_test.go | 7 +- internal/guard/filekind_test.go | 6 +- internal/guard/filenamehandling_test.go | 6 +- internal/guard/generatewindow_test.go | 9 + internal/guard/instructions_test.go | 307 ++++++++++++++++++++++++ internal/guard/parity_test.go | 2 + internal/guard/presetbytes_test.go | 46 ++-- internal/guard/presetcommands_test.go | 31 ++- internal/guard/presetwindow_test.go | 26 +- internal/guard/recipeshapes_test.go | 1 + internal/guard/settingslot_test.go | 4 +- internal/gui/text/locale/en.json | 20 ++ internal/gui/text/screens.go | 7 + internal/gui/text/text.go | 14 ++ internal/gui/window/preset.go | 2 + internal/gui/window/recipe.go | 38 +-- internal/gui/window/recipefolds.go | 74 +++++- internal/gui/window/run.go | 19 +- internal/gui/window/runactions.go | 2 +- internal/gui/window/runoffers.go | 70 ++++-- internal/gui/window/runreport.go | 17 +- internal/manifest/instructions.go | 267 +++++++++++++++++++++ internal/manifest/manifest.go | 40 ++- internal/preset/build.go | 5 + internal/preset/emptyandminimal.go | 4 + internal/preset/filenamehandling.go | 155 ++++++++---- internal/preset/limitset.go | 18 ++ internal/preset/tabularimport.go | 27 +++ internal/preset/textencoding.go | 33 +++ internal/preset/uploadfiles.go | 202 ++++++++++++++++ internal/preset/uploadset.go | 159 ------------ internal/recipe/compose.go | 6 + internal/recipe/recipe.go | 12 +- internal/recipe/target.go | 15 +- 46 files changed, 1694 insertions(+), 442 deletions(-) create mode 100644 internal/cli/record.go create mode 100644 internal/engine/record.go create mode 100644 internal/guard/instructions_test.go create mode 100644 internal/manifest/instructions.go create mode 100644 internal/preset/uploadfiles.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d53389..999b9162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ because it turns other people's test suites red. ### Changed +- **`tfg preset eject` writes a `purpose` line for every target, so an + ejected recipe and the `recipe_hash` of every run from a preset differ from + 0.3.0.** The recipe carries the purposes so that an ejected preset still + produces exactly what the preset does. The bytes of every generated file + are unchanged. A pipeline comparing `recipe_hash` across tool versions will + see a new value once. + - **A file name longer than 255 bytes is refused before anything is written, on every system.** Linux stores at most 255 bytes in a name, while Windows and macOS count characters, so a name of 200 Chinese or Japanese characters @@ -307,6 +314,29 @@ because it turns other people's test suites red. ### Added +- **Every preset now says what each of its files is for.** A run from a + preset writes `manifest.instructions.md` beside `manifest.json`: the + question the preset answers, how to read accept, reject, sanitize and + unspecified, and then every file - its name, format and size, what your + system is expected to do with it and one or two sentences on why it is in + the set. Many files of one kind, such as the fifty of a mass upload, are + one entry. The file is named after the manifest, so `run2.json` gets + `run2.instructions.md`, and it is written only when some file has a + purpose - a plain `tfg generate --format png` writes none. `generate` + prints `instructions:` under `manifest:`, `verify` does not count the file + as extra, and `cleanup --with-manifest` removes it with the manifest. A + run into a directory that already holds instructions of that name is + refused before anything is written. If the file cannot be written, the run + says so and still succeeds, because every file and the manifest are + complete. + +- **A `purpose` for every target of a recipe.** One or two sentences on what + the files are and why they are in the set. They reach `files[].purpose` in + the manifest and the instructions beside it, and change no byte of any + file. The batch screen has a `Purpose` box among the manifest notes of each + batch, and the window offers `Open instructions` beside `Open manifest` + after a run that wrote them. + - **A preset for unusual file names: `filename-handling`.** It answers "will my system store, show and give back a file name it did not expect?" with fifty names in seven groups: scripts from Polish to Korean, names that look diff --git a/README.md b/README.md index 81ed10fb..04d1780e 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,10 @@ for entry in manifest["files"]: assert not response.ok, entry["path"] ``` +A run from a preset also writes `manifest.instructions.md` - the same facts for +a person to read: every file, what your system should do with it and why the +file is in the set. + And where the right answer genuinely depends on your own policy, the manifest says `unspecified` instead of inventing one. A generator that guesses produces false failures, and a suite that cries wolf gets switched off. diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 890a6ea8..6e5e8f03 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -244,7 +244,8 @@ func Claimed(m *manifest.Manifest) []manifest.File { // the manifest usually sits in the directory it describes, and a tool that // fails on its own output on the most obvious invocation is not usable. // Matched on the base name rather than the path, because a restored copy -// carries its own copy of the manifest beside the files. +// carries its own copy of the manifest beside the files. The instructions the +// manifest names are its own output in the same way, and skipped the same way. // // A run that is cancelled reports what it managed to compare and says so // through the context error. Reporting "sound" on the strength of half a @@ -309,7 +310,7 @@ func Verify(ctx context.Context, dir string, m *manifest.Manifest, skip string) // decided. walk builds these with filepath.Rel, which returns a clean // path, so a comparablePath here is a call that cannot be wrong - // removing it left this guard green. See the comment on comparablePath. - if seen[p] || filepath.Base(p) == skip { + if seen[p] || filepath.Base(p) == skip || (m.Run.Instructions != "" && filepath.Base(p) == m.Run.Instructions) { continue } unclaimed = append(unclaimed, p) diff --git a/internal/cli/cleanup.go b/internal/cli/cleanup.go index 536e4ecf..2ed1902d 100644 --- a/internal/cli/cleanup.go +++ b/internal/cli/cleanup.go @@ -97,7 +97,22 @@ Flags: if !*yes { return previewCleanup(cands, path, dir, *force, *asJSON, out, errOut) } - return applyCleanup(ctx, cands, path, dir, *force, *withManifest, *asJSON, out, errOut) + var record []string + if *withManifest { + record = recordOf(path, m) + } + return applyCleanup(ctx, cands, path, dir, *force, record, *asJSON, out, errOut) +} + +// recordOf is what a run wrote about itself: the manifest, and the +// instructions it names when it names any, which sit beside it. Load has +// already refused a manifest naming anything but a plain name there. +func recordOf(path string, m *manifest.Manifest) []string { + record := []string{path} + if m.Run.Instructions != "" { + record = append(record, filepath.Join(filepath.Dir(path), m.Run.Instructions)) + } + return record } // previewCleanup lists what a run with --yes would remove, and removes nothing. @@ -130,7 +145,10 @@ func previewCleanup(cands []audit.Candidate, path, dir string, force, asJSON boo } // applyCleanup removes the files and says exactly what happened to each. -func applyCleanup(ctx context.Context, cands []audit.Candidate, path, dir string, force, withManifest, asJSON bool, out, errOut io.Writer) int { +// +// record is the manifest and the instructions beside it, when --with-manifest +// asked for them to go too, and nothing otherwise. +func applyCleanup(ctx context.Context, cands []audit.Candidate, path, dir string, force bool, record []string, asJSON bool, out, errOut io.Writer) int { outcomes, removeErr := audit.Remove(ctx, dir, cands, force) // A file that was already gone is not a leftover. Counting it as one would @@ -175,12 +193,11 @@ func applyCleanup(ctx context.Context, cands []audit.Candidate, path, dir string return ExitInterrupted } - if withManifest { + if len(record) > 0 { if blocked > 0 { fmt.Fprintf(errOut, "tfg: the manifest was kept. It is the only record of %s still on disk.\n", core.Count(blocked, "file", "files")) - } else if err := os.Remove(path); err != nil { - fmt.Fprintf(errOut, "tfg: cannot remove the manifest %s: %s\n", core.Shown(path), describeError(err)) - return ExitIO + } else if code := removeRecord(record, errOut); code != ExitOK { + return code } } @@ -266,3 +283,24 @@ func boolToInt(b bool) int { } return 0 } + +// removeRecord takes away what a run wrote about itself, the instructions +// before the manifest that names them - so a failure part way leaves the +// manifest standing beside what it names rather than naming something gone. +// Instructions already gone are not a failure: somebody deleting a page of +// prose is no reason to keep a manifest they asked to have removed. +func removeRecord(record []string, errOut io.Writer) int { + for i := len(record) - 1; i >= 0; i-- { + err := os.Remove(record[i]) + if err == nil || (i > 0 && errors.Is(err, os.ErrNotExist)) { + continue + } + what := "the manifest" + if i > 0 { + what = "the instructions" + } + fmt.Fprintf(errOut, "tfg: cannot remove %s %s: %s\n", what, core.Shown(record[i]), describeError(err)) + return ExitIO + } + return ExitOK +} diff --git a/internal/cli/generate.go b/internal/cli/generate.go index 50e25bfd..56b3ede3 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -490,54 +490,6 @@ func produce(ctx context.Context, targets []engine.Target, opt engine.Options, g return ExitOK } -// whatSurvived says what a stopped run left behind, for the sentence above. -// -// The window has said this since it had a progress bar - "Stopped after N -// files. The manifest describes exactly those." The command line said "context -// canceled" and left the reader to work out whether the directory was safe to -// reuse. Same run, same facts, and only one surface was saying them. -// -// Only for a stop, and only for a run that got past its preflight. A run -// refused before it wrote anything has nothing to describe, and a run that -// failed for its own reason already says what went wrong in its own words - -// adding a count to either would be answering a question nobody asked. -// -// The claim it makes is the one the manifest keeps: every file that reached the -// disk has an entry, hole allowed. That is the row of the regression surface -// about a stopped run naming what it produced, and it is what makes "tfg -// cleanup" able to take them away again. -// -// PROVEN BY RUNNING IT, NOT BY A GUARD, and that is worth knowing before -// trusting it. Measured on 2026-09-06 in a Linux container against a real -// signal, because a signal cannot be delivered to this process from the shell -// on the machine this was written on: -// -// SIGINT into 3000 files exit 130 "897 files written" 897 on disk -// SIGTERM into 3000 files exit 143 "755 files written" 755 on disk -// -// A guard reaches the sentence but not the count. The command line plans before -// it runs, and planning honours the context, so a run started with a finished -// context returns from PlanContext and never arrives here - res is nil and the -// count is never built. Landing between the two needs a cancel timed to arrive -// after planning and before the last file, which is a clock, and a guard built -// on a clock goes red on a busy machine rather than on a defect. -// -// So !res.Started is not reddenable from this surface today. It stays because -// the state it refuses is reachable in the engine - Run sets Manifest at -// construction and Started only after preflight, so a stop returned between -// those two would otherwise print "0 files written, and the manifest describes -// exactly those" about a run that wrote nothing and saved no manifest. That is -// an invented fact rather than a missing one, which is the half of untouchable -// rule 5 that costs trust. -func whatSurvived(runErr error, res *engine.Result) string { - stopped := errors.Is(runErr, context.Canceled) || errors.Is(runErr, context.DeadlineExceeded) - if !stopped || res == nil || !res.Started || res.Manifest == nil { - return "" - } - return fmt.Sprintf(" %s written, and the manifest describes exactly those.", - core.Count(len(res.Manifest.Files), "file", "files")) -} - // defaultManifestName is where the manifest lands when nothing says otherwise. // echoBoundaries spells out a boundary set, because a boundary set exists to @@ -571,37 +523,6 @@ func echoBoundaries(targets []engine.Target, planned []engine.PlannedFile, errOu } } -// echoManifestReach says so when a run will write a manifest this build cannot -// read back. -// -// Measured on 2026-08-26: a run of 25 000 files wrote a manifest of 25 220 640 -// B against a read ceiling of 16 777 216 B, and from that point "tfg verify" -// and "tfg cleanup" both exited 5 on it. The files stayed on the disk and -// nothing in this toolset could remove them, because the manifest is the only -// authority over what may be deleted. -// -// A note rather than a refusal, by the owner's decision on the same day: the -// run itself works, and refusing it would take away something this tool does -// today. What was missing was that nobody was told. It is printed before the -// first byte and on a dry run, which is the step this project tells people to -// take before anything large. -func echoManifestReach(planned []engine.PlannedFile, errOut io.Writer) { - noted := 0 - for _, f := range planned { - if len(f.Plan.Notes) > 0 { - noted++ - } - } - size, over := manifest.TooLargeToReadBack(len(planned), noted) - if !over { - return - } - fmt.Fprintf(errOut, - "note: this run will write a manifest of roughly %s and this build reads at most %s, "+ - "so tfg verify and tfg cleanup will not be able to read it. Split the run to keep each manifest readable.\n", - core.HumanBytes(size), core.HumanBytes(manifest.MaxBytes)) -} - // engineTarget turns one recipe target into one engine target. // // One function rather than one per command, and that is a repair. There were @@ -631,6 +552,7 @@ func engineTarget(t recipe.Target, label bool) engine.Target { Expected: t.Expected, ExpectedReason: t.ExpectedReason, Group: t.Group, + Purpose: t.Purpose, Properties: t.Properties, Damage: t.Damage, } @@ -649,28 +571,3 @@ func contentsOf(t recipe.Target) []format.Content { } return out } - -func saveManifest(res *engine.Result, opt engine.Options, errOut io.Writer) int { - // Asked of the engine rather than joined here. The engine claimed this - // exact name before the first file was written, so working it out a second - // way is a chance for the saver and the claim to mean different files. - path := engine.ManifestPath(opt) - if err := res.Manifest.Save(path); err != nil { - fmt.Fprintf(errOut, "tfg: cannot write the manifest to %s: %s\n", core.Shown(path), describeError(err)) - // What that leaves behind, because the line above is about the manifest - // and the person's problem is the files. Rule 6: a run that wrote files - // nothing can remove says so rather than leaving it to be discovered by - // running cleanup and being told the manifest will not parse. - // - // Nothing that agrees with the number, on purpose - see core.Count. "3 - // files written" reads the same at one as at three. - if n := len(res.Manifest.Files); n > 0 { - fmt.Fprintf(errOut, - "tfg: %s written and nothing to record what this run left. Cleanup works from a manifest, so clearing %s is a job by hand.\n", - core.Count(n, "file", "files"), core.Shown(opt.OutDir)) - } - return ExitIO - } - fmt.Fprintf(errOut, "manifest: %s\n", core.Shown(path)) - return ExitOK -} diff --git a/internal/cli/preset.go b/internal/cli/preset.go index 99b7b8d8..36db0b57 100644 --- a/internal/cli/preset.go +++ b/internal/cli/preset.go @@ -38,6 +38,7 @@ func record(e *preset.Expansion) *manifest.Preset { } return &manifest.Preset{ ID: e.Preset.ID, + Question: e.Preset.Question, Parameters: map[string]string(e.Settled), Defaulted: e.Defaulted, } diff --git a/internal/cli/record.go b/internal/cli/record.go new file mode 100644 index 00000000..d56e4ff4 --- /dev/null +++ b/internal/cli/record.go @@ -0,0 +1,139 @@ +// Part of package cli. See cli.go. +package cli + +// What a run leaves about itself once the files are written: the manifest and +// the instructions beside it, and what is said when a run stopped or its record +// will be too large to read back. Out of generate.go on 2026-09-25, when the +// instructions made that file the largest in the tree. + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/engine" + "github.com/donislawdev/TestingFilesGenerator/internal/manifest" +) + +// whatSurvived says what a stopped run left behind, for the sentence produce +// prints about a run that ended early. +// +// The window has said this since it had a progress bar - "Stopped after N +// files. The manifest describes exactly those." The command line said "context +// canceled" and left the reader to work out whether the directory was safe to +// reuse. Same run, same facts, and only one surface was saying them. +// +// Only for a stop, and only for a run that got past its preflight. A run +// refused before it wrote anything has nothing to describe, and a run that +// failed for its own reason already says what went wrong in its own words - +// adding a count to either would be answering a question nobody asked. +// +// The claim it makes is the one the manifest keeps: every file that reached the +// disk has an entry, hole allowed. That is the row of the regression surface +// about a stopped run naming what it produced, and it is what makes "tfg +// cleanup" able to take them away again. +// +// PROVEN BY RUNNING IT, NOT BY A GUARD, and that is worth knowing before +// trusting it. Measured on 2026-09-06 in a Linux container against a real +// signal, because a signal cannot be delivered to this process from the shell +// on the machine this was written on: +// +// SIGINT into 3000 files exit 130 "897 files written" 897 on disk +// SIGTERM into 3000 files exit 143 "755 files written" 755 on disk +// +// A guard reaches the sentence but not the count. The command line plans before +// it runs, and planning honours the context, so a run started with a finished +// context returns from PlanContext and never arrives here - res is nil and the +// count is never built. Landing between the two needs a cancel timed to arrive +// after planning and before the last file, which is a clock, and a guard built +// on a clock goes red on a busy machine rather than on a defect. +// +// So !res.Started is not reddenable from this surface today. It stays because +// the state it refuses is reachable in the engine - Run sets Manifest at +// construction and Started only after preflight, so a stop returned between +// those two would otherwise print "0 files written, and the manifest describes +// exactly those" about a run that wrote nothing and saved no manifest. That is +// an invented fact rather than a missing one, which is the half of untouchable +// rule 5 that costs trust. +func whatSurvived(runErr error, res *engine.Result) string { + stopped := errors.Is(runErr, context.Canceled) || errors.Is(runErr, context.DeadlineExceeded) + if !stopped || res == nil || !res.Started || res.Manifest == nil { + return "" + } + return fmt.Sprintf(" %s written, and the manifest describes exactly those.", + core.Count(len(res.Manifest.Files), "file", "files")) +} + +// echoManifestReach says so when a run will write a manifest this build cannot +// read back. +// +// Measured on 2026-08-26: a run of 25 000 files wrote a manifest of 25 220 640 +// B against a read ceiling of 16 777 216 B, and from that point "tfg verify" +// and "tfg cleanup" both exited 5 on it. The files stayed on the disk and +// nothing in this toolset could remove them, because the manifest is the only +// authority over what may be deleted. +// +// A note rather than a refusal, by the owner's decision on the same day: the +// run itself works, and refusing it would take away something this tool does +// today. What was missing was that nobody was told. It is printed before the +// first byte and on a dry run, which is the step this project tells people to +// take before anything large. +func echoManifestReach(planned []engine.PlannedFile, errOut io.Writer) { + noted := 0 + for _, f := range planned { + if len(f.Plan.Notes) > 0 { + noted++ + } + } + size, over := manifest.TooLargeToReadBack(len(planned), noted) + if !over { + return + } + fmt.Fprintf(errOut, + "note: this run will write a manifest of roughly %s and this build reads at most %s, "+ + "so tfg verify and tfg cleanup will not be able to read it. Split the run to keep each manifest readable.\n", + core.HumanBytes(size), core.HumanBytes(manifest.MaxBytes)) +} + +// saveManifest saves the record of a run - the manifest, and the instructions +// beside it when any file was given a purpose - and says where each went. +func saveManifest(res *engine.Result, opt engine.Options, errOut io.Writer) int { + // Asked of the engine rather than joined here. The engine claimed this + // exact name before the first file was written, so working it out a second + // way is a chance for the saver and the claim to mean different files. + // + // The instructions are saved with it, by the same function the window + // calls, so the two surfaces leave the same directory behind. + rec, err := engine.SaveRecord(res, opt) + path := rec.Manifest + if err != nil { + fmt.Fprintf(errOut, "tfg: cannot write the manifest to %s: %s\n", core.Shown(path), describeError(err)) + // What that leaves behind, because the line above is about the manifest + // and the person's problem is the files. Rule 6: a run that wrote files + // nothing can remove says so rather than leaving it to be discovered by + // running cleanup and being told the manifest will not parse. + // + // Nothing that agrees with the number, on purpose - see core.Count. "3 + // files written" reads the same at one as at three. + if n := len(res.Manifest.Files); n > 0 { + fmt.Fprintf(errOut, + "tfg: %s written and nothing to record what this run left. Cleanup works from a manifest, so clearing %s is a job by hand.\n", + core.Count(n, "file", "files"), core.Shown(opt.OutDir)) + } + return ExitIO + } + fmt.Fprintf(errOut, "manifest: %s\n", core.Shown(path)) + if rec.Instructions != "" { + fmt.Fprintf(errOut, "instructions: %s\n", core.Shown(rec.Instructions)) + } + // Said rather than failed. Every file and the manifest are whole, and the + // manifest holds the same facts the instructions would have put in words. + if rec.Missed != nil { + fmt.Fprintf(errOut, + "tfg: cannot write the instructions to %s: %s. The files and the manifest are complete, and the manifest holds the same facts.\n", + core.Shown(rec.Missed.Path), describeError(rec.Missed.Err)) + } + return ExitOK +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index e5f5c1e6..dffaa94f 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -79,7 +79,11 @@ type Target struct { ExpectedReason string // Group names the class of case these files belong to and reaches the // manifest, so a test can assert about a whole class at once. - Group string + Group string + // Purpose is what these files are and why they are in the set, in words. + // It reaches the manifest and the instructions beside it, and never the + // seed - rewording it moves no byte of any file. + Purpose string Properties map[string]string // Damage is what to break about these files, in the order to break it. // @@ -368,6 +372,18 @@ func PlanContext(ctx context.Context, targets []Target, opt Options) ([]PlannedF // pointed straight at it. pl.names[collisionKey(manifestNameOf(opt))] = nameOwner{ name: manifestNameOf(opt), manifest: true} + // The instructions beside the manifest hold their name the same way, in a + // run that will write them. Their name is the manifest's with a longer + // ending, so a manifest named as long as a system allows gives + // instructions it does not - refused here, on the manifest's box, before + // anything is written rather than after every file. + if explained(targets) { + name := instructionsNameOf(opt) + if err := checkFileName(SettingOutputManifest, "the instructions beside the manifest", name); err != nil { + return nil, err + } + pl.names[collisionKey(name)] = nameOwner{name: name, instructions: true} + } if opt.OutDir == "" { return nil, &RecipeError{Setting: SettingOutDir, @@ -662,6 +678,7 @@ func entryFor(f PlannedFile, sha string, materialized bool, failure error) manif Notes: notes, Expected: expectationFor(f), Group: f.Target.Group, + Purpose: f.Target.Purpose, TargetID: f.Target.ID, } diff --git a/internal/engine/errors.go b/internal/engine/errors.go index 49ef0006..b3e04c34 100644 --- a/internal/engine/errors.go +++ b/internal/engine/errors.go @@ -243,9 +243,17 @@ type CollisionError struct { // costs the record of every file an earlier run wrote - after which // cleanup cannot see them and nothing can. Manifest bool + // Instructions is set when what is in the way is the instructions of an + // earlier run, which describe that run's files. + Instructions bool } func (e *CollisionError) Error() string { + if e.Instructions { + return fmt.Sprintf( + "%s already exists and this run will not write over it. It says what the files of an earlier run are for. Generate into an empty directory, or name this run's manifest something else", + core.Shown(e.Path)) + } if e.Manifest { return fmt.Sprintf( "%s already exists and this run will not write over it. It is the only record of what an earlier run wrote, so replacing it would leave those files with nothing to remove them by. Generate into an empty directory, or move the old manifest aside", diff --git a/internal/engine/names.go b/internal/engine/names.go index 16324229..85b9fad1 100644 --- a/internal/engine/names.go +++ b/internal/engine/names.go @@ -27,6 +27,10 @@ type nameOwner struct { // with a choice nobody else can make, while a target sitting on the // manifest has one box that is certainly filled in - its own. manifest bool + // instructions is set for the file written beside the manifest that says + // what every file is for. Refused the same way the manifest is, in words + // of its own. + instructions bool } // claimFileName takes a name for one target, or refuses because somebody has it. @@ -54,6 +58,15 @@ func claimFileName(names map[string]nameOwner, position int, id, name string) er Remedy: "Give the target a name template containing " + indexToken + ", or name the manifest something else", } } + if owner.instructions { + return &RecipeError{ + Setting: core.TargetAddress(position, SettingName), + Detail: fmt.Sprintf("target %q produces a file named %s, and that is the name this run gives the instructions beside its manifest", + id, core.Shown(name)), + Because: "both are written into the output directory, and the instructions say what every file of the run is for, so one of the two would be lost", + Remedy: "Give the target a name template containing " + indexToken + ", or name the manifest something else", + } + } // No address, deliberately, and this is the one refusal here that keeps it. // Two targets produce the pair, so naming one of them would send somebody to // a box that is not wrong on its own - and which of the two to change is diff --git a/internal/engine/preflight.go b/internal/engine/preflight.go index 12958e0f..60a1d010 100644 --- a/internal/engine/preflight.go +++ b/internal/engine/preflight.go @@ -9,6 +9,7 @@ import ( "path/filepath" "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/manifest" ) // What has to be true before a run may start, and where the things it writes @@ -80,6 +81,14 @@ func preflight(ctx context.Context, files []PlannedFile, opt Options) error { if path := ManifestPath(opt); exists(path) { return &CollisionError{Path: path, Manifest: true} } + // And the instructions beside it, in a run that will write them. An + // earlier run's instructions describe that run's files, and a second run + // writing over them would leave its manifest beside words about others. + if explainedFiles(files) { + if path := InstructionsPath(opt); exists(path) { + return &CollisionError{Path: path, Instructions: true} + } + } // Nothing else is written over either. This tool runs in directories that // belong to the user, so destroying their work is the one failure that @@ -226,6 +235,40 @@ func ManifestPath(opt Options) string { return filepath.Join(opt.OutDir, manifestNameOf(opt)) } +// instructionsNameOf is the name of the instructions beside this run's +// manifest - its name with a different ending, see manifest.InstructionsName. +func instructionsNameOf(opt Options) string { + return manifest.InstructionsName(manifestNameOf(opt)) +} + +// InstructionsPath is where this run's instructions land, when it writes any. +// Exported for the reason ManifestPath is: the check, the reservation, the +// save and the window's button have to mean the same file. +func InstructionsPath(opt Options) string { + return filepath.Join(opt.OutDir, instructionsNameOf(opt)) +} + +// explained says whether any target was given a purpose, which is when a run +// writes instructions beside its manifest. +func explained(targets []Target) bool { + for _, t := range targets { + if t.Purpose != "" { + return true + } + } + return false +} + +// explainedFiles is explained asked of a plan. +func explainedFiles(files []PlannedFile) bool { + for _, f := range files { + if f.Target.Purpose != "" { + return true + } + } + return false +} + // RunLockPath is the name a run holds while it writes into a directory. // // Exported for the same reason ManifestPath is: more than one part of the tool diff --git a/internal/engine/record.go b/internal/engine/record.go new file mode 100644 index 00000000..0ec475c4 --- /dev/null +++ b/internal/engine/record.go @@ -0,0 +1,68 @@ +package engine + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/manifest" +) + +// Record is what a run saved beside its files to say what they are. +type Record struct { + // Manifest is where the manifest was saved. + Manifest string + // Instructions is where the instructions were saved, and empty when the + // run wrote none: no file was given a purpose, or writing them failed. + Instructions string + // Missed is set when instructions were due and are not there. The run is + // not failed by it - every file and the manifest are whole - and the + // caller says it out loud rather than letting it pass. + Missed *InstructionsError +} + +// InstructionsError is instructions that were due and could not be written. +type InstructionsError struct { + Path string + Err error +} + +func (e *InstructionsError) Error() string { + return fmt.Sprintf("cannot write the instructions to %s: %v", core.Shown(e.Path), e.Err) +} + +func (e *InstructionsError) Unwrap() error { return e.Err } + +// SaveRecord writes the instructions, when any file of the run was given a +// purpose, and then the manifest, which names them. +// +// One function for the command line and the window, and that is the reason it +// exists. Each surface saved the manifest its own way, and a second file +// written twice would be two chances for the two surfaces to leave different +// directories behind (D1). +// +// The instructions first, so that the manifest only names a file that is on +// the disk. A manifest that cannot be saved takes the instructions with it: +// they describe files nothing records, and cleanup would have no way to them. +func SaveRecord(res *Result, opt Options) (Record, error) { + rec := Record{Manifest: ManifestPath(opt)} + m := res.Manifest + if text := m.Instructions(filepath.Base(rec.Manifest)); text != nil { + path := InstructionsPath(opt) + if err := manifest.SaveInstructions(path, text); err != nil { + rec.Missed = &InstructionsError{Path: path, Err: err} + } else { + rec.Instructions = path + m.Run.Instructions = filepath.Base(path) + } + } + if err := m.Save(rec.Manifest); err != nil { + if rec.Instructions != "" { + _ = os.Remove(rec.Instructions) + rec.Instructions, m.Run.Instructions = "", "" + } + return rec, err + } + return rec, nil +} diff --git a/internal/guard/codeshape_test.go b/internal/guard/codeshape_test.go index f4bb72a1..4a52a91c 100644 --- a/internal/guard/codeshape_test.go +++ b/internal/guard/codeshape_test.go @@ -53,7 +53,12 @@ const ( // Lowered from 408 on 2026-09-24: the rules a file name has to pass moved // out of engine.go into filename.go when the length rule (O239) took it // past the ceiling. The longest file is cli/generate.go now. - longestFile = 407 + // Lowered from 407 on 2026-09-25: saving a run's record moved out of + // cli/generate.go into record.go, and the file groups of the upload set + // out of preset/uploadset.go into uploadfiles.go, when the instructions + // beside the manifest took both past the ceiling. The longest file is + // manifest/manifest.go now. + longestFile = 401 // Depth answers a different question than length, and it is the better // question of the two. A hundred line function that is flat reads top to diff --git a/internal/guard/filekind_test.go b/internal/guard/filekind_test.go index 353778b1..03a348cb 100644 --- a/internal/guard/filekind_test.go +++ b/internal/guard/filekind_test.go @@ -46,7 +46,7 @@ func TestThePresetScreenCanBuildTheSetInAnyFormat(t *testing.T) { written := namesIn(t, dir) pictures := 0 for _, name := range written { - if name == "manifest.json" { + if isRecord(name) { continue } if !strings.HasSuffix(name, ".png") { @@ -102,8 +102,8 @@ func TestChoosingTheFormatGivesTheSameSetOnBothSurfaces(t *testing.T) { t.Fatalf("the two surfaces produced different files.\n command line: %v\n window: %v", cliNames, windowNames) } - if len(cliNames) != 8 { - t.Fatalf("the preset produced %d thing(s) and seven files plus a manifest was expected: %v", + if len(cliNames) != 9 { + t.Fatalf("the preset produced %d thing(s) and seven files, a manifest and its instructions were expected: %v", len(cliNames), cliNames) } diff --git a/internal/guard/filenamehandling_test.go b/internal/guard/filenamehandling_test.go index b84ecdc4..5a334752 100644 --- a/internal/guard/filenamehandling_test.go +++ b/internal/guard/filenamehandling_test.go @@ -227,7 +227,7 @@ func TestTheFileNamePresetWritesEveryNameByteForByte(t *testing.T) { sort.Strings(recorded) var onDisk []string for _, name := range namesIn(t, dir) { - if name != "manifest.json" { + if !isRecord(name) { onDisk = append(onDisk, name) } } @@ -258,8 +258,8 @@ func TestThePresetScreenWritesTheNamesTheCommandLineWrites(t *testing.T) { join(host) cliNames, windowNames := namesIn(t, fromCLI), namesIn(t, fromWindow) - if len(cliNames) != 51 { - t.Fatalf("the command line wrote %d things and fifty files and a manifest were expected", len(cliNames)) + if len(cliNames) != 52 { + t.Fatalf("the command line wrote %d things and fifty files, a manifest and its instructions were expected", len(cliNames)) } if strings.Join(cliNames, "\x00") != strings.Join(windowNames, "\x00") { t.Fatalf("the two surfaces wrote different names:\n command line %+q\n window %+q", cliNames, windowNames) diff --git a/internal/guard/generatewindow_test.go b/internal/guard/generatewindow_test.go index f837c524..98474fb4 100644 --- a/internal/guard/generatewindow_test.go +++ b/internal/guard/generatewindow_test.go @@ -16,9 +16,11 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/format" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" + "github.com/donislawdev/TestingFilesGenerator/internal/manifest" ) // The generate screen, exercised the way somebody uses it: fields are filled @@ -699,6 +701,13 @@ func join(host *fakeHost) { } } +// isRecord says whether a name in an output directory is what the run wrote +// about itself - the manifest, or the instructions beside it - rather than a +// file of the set. +func isRecord(name string) bool { + return name == engine.DefaultManifestName || name == manifest.InstructionsName(engine.DefaultManifestName) +} + func namesIn(t *testing.T, dir string) []string { t.Helper() entries, err := os.ReadDir(dir) diff --git a/internal/guard/instructions_test.go b/internal/guard/instructions_test.go new file mode 100644 index 00000000..6a218cd0 --- /dev/null +++ b/internal/guard/instructions_test.go @@ -0,0 +1,307 @@ +package guard + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/manifest" + "github.com/donislawdev/TestingFilesGenerator/internal/preset" + "github.com/donislawdev/TestingFilesGenerator/internal/recipe" +) + +// The instructions beside a manifest say what every file is for, and these +// guards hold the rules they were built on (docs/PRESET-INSTRUCTIONS-2026-09-25.md). + +// instructionsName is what the instructions of a run with the default +// manifest are called. +var instructionsName = manifest.InstructionsName("manifest.json") + +// Every file of every preset says what it is for, in words a person can read. +// +// The owner's request of 2026-09-25 was exactly this: a preset writes dozens of +// files and nothing said what any of them was for. A target left without a +// purpose would be a line in the instructions saying no purpose was given, in +// a set this tool built itself. Asked of the ejected recipe rather than of the +// preset's tables, because that is what reaches the manifest - and PR5 says the +// two are the same thing. +// +// D17 as well, because the sentences are text a person reads: no semicolon and +// no long dash. +func TestEveryFileOfEveryPresetSaysWhatItIsFor(t *testing.T) { + for _, id := range preset.IDs() { + expanded, err := preset.Expand(id, preset.Args{}) + if err != nil { + t.Fatalf("%s refused its own defaults: %v", id, err) + } + rec, err := recipe.Parse(expanded.Source, id) + if err != nil { + t.Fatalf("the recipe %s ejects does not read back: %v", id, err) + } + if len(rec.Targets) == 0 { + t.Fatalf("%s has no targets, so nothing was asked of it", id) + } + for _, target := range rec.Targets { + switch p := target.Purpose; { + case strings.TrimSpace(p) == "": + t.Errorf("%s: target %q says nothing about what its files are for", id, target.ID) + case strings.ContainsAny(p, ";"+string(rune(0x2014))+string(rune(0x2013))): + t.Errorf("%s: the purpose of %q carries a semicolon or a long dash (D17): %s", id, target.ID, p) + } + } + } +} + +// A name reaches the reader of the instructions as text, whatever it holds. +// +// filename-handling writes names built to be read as something else - a +// command in backticks, a right to left override, a space at each end - and the +// instructions list every one of them. A name that Markdown reads as markup +// shows the reader a different file, which is the one thing the instructions +// exist to prevent. +func TestTheInstructionsShowEveryNameAsItIsAndNothingElse(t *testing.T) { + override := string(rune(0x202E)) + names := []string{"a`b.txt", "`start.txt", " both ends ", "photo" + override + "gpj.txt", "plain.txt"} + m := manifest.New("tfg", "0.0.0", "run", "tfg", 0, "os", "arch") + for _, name := range names { + m.Add(manifest.File{Name: name, Path: name, Materialized: true, Format: "txt", Bytes: 1, + TargetID: "t" + name, Group: "names", Purpose: "line one\nline two", + Expected: manifest.Expected{Outcome: "accept"}}) + } + md := string(m.Instructions("manifest.json")) + for _, want := range []string{"``a`b.txt``", "`` `start.txt ``", "` both ends `", "`" + core.Shown("photo"+override+"gpj.txt") + "`", "`plain.txt`"} { + if !strings.Contains(md, want) { + t.Errorf("the instructions do not show a name as %s:\n%s", want, md) + } + } + if strings.Contains(md, override) { + t.Error("a right to left override reached the instructions raw, so the name is shown as another one") + } + if !strings.Contains(md, " line one line two\n") { + t.Errorf("a purpose written across two lines broke the list rather than standing on one line:\n%s", md) + } +} + +// Many files of one target read as one entry, and a file that failed as its own. +func TestTheInstructionsFoldARunOfOneTargetIntoOneEntry(t *testing.T) { + m := manifest.New("tfg", "0.0.0", "run", "tfg", 0, "os", "arch") + for _, name := range []string{"bulk_0001.jpg", "bulk_0002.jpg", "bulk_0003.jpg"} { + m.Add(manifest.File{Name: name, Path: name, Materialized: true, Format: "jpg", Bytes: 10, + TargetID: "bulk", Purpose: "Many files at once.", Expected: manifest.Expected{Outcome: "accept"}}) + } + m.Add(manifest.File{Name: "bulk_0004.jpg", Path: "bulk_0004.jpg", Format: "jpg", TargetID: "bulk", + Failed: true, Error: "the disk said no", Purpose: "Many files at once."}) + md := string(m.Instructions("manifest.json")) + if !strings.Contains(md, "- 3 files, `bulk_0001.jpg` to `bulk_0003.jpg` - jpg, 30 B in all.") { + t.Errorf("three files of one target were not one entry:\n%s", md) + } + if !strings.Contains(md, "- `bulk_0004.jpg`") || !strings.Contains(md, "Not written: the disk said no") { + t.Errorf("a file that failed was folded in with the rest rather than said on its own:\n%s", md) + } + if got := strings.Count(md, "Many files at once."); got != 2 { + t.Errorf("the purpose is said %d times, and once for the run and once for the failed file was expected", got) + } +} + +// A recipe with a purpose leaves instructions, verify does not take them for +// something nobody asked for, and they go with the manifest and not before it. +func TestTheInstructionsGoWhereTheManifestGoes(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out") + generateFrom(t, dir, out, "purpose: A file for the guard.\n") + if _, err := os.Stat(filepath.Join(out, instructionsName)); err != nil { + t.Fatalf("a recipe with a purpose left no instructions: %v", err) + } + if got := recordedInstructions(t, out); got != instructionsName { + t.Fatalf("the manifest names %q as its instructions, and %q was written", got, instructionsName) + } + if code, said := tfg("verify", filepath.Join(out, "manifest.json")); code != cli.ExitOK { + t.Fatalf("verify ended %d on a directory the run just wrote, so it took the instructions for something else:\n%s", code, said) + } + if code, said := tfg("cleanup", filepath.Join(out, "manifest.json"), "--yes"); code != cli.ExitOK { + t.Fatalf("cleanup ended %d: %s", code, said) + } + if _, err := os.Stat(filepath.Join(out, instructionsName)); err != nil { + t.Fatal("cleanup without --with-manifest took the instructions, which describe the manifest that stayed") + } + if code, said := tfg("cleanup", filepath.Join(out, "manifest.json"), "--yes", "--with-manifest"); code != cli.ExitOK { + t.Fatalf("cleanup --with-manifest ended %d: %s", code, said) + } + if left := namesIn(t, out); len(left) != 0 { + t.Errorf("cleanup --with-manifest left %v behind", left) + } +} + +// A run nobody explained writes no instructions and names none. +func TestARunWithNoPurposeWritesNoInstructions(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out") + generateFrom(t, dir, out, "") + if _, err := os.Stat(filepath.Join(out, instructionsName)); err == nil { + t.Error("a run with no purpose anywhere wrote instructions that could only say nothing") + } + if got := recordedInstructions(t, out); got != "" { + t.Errorf("the manifest names instructions %q that nobody wrote", got) + } +} + +// Instructions an earlier run left are never written over, and a target +// cannot take their name. +func TestTheNameOfTheInstructionsIsHeldBeforeAnyFile(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out") + if err := os.MkdirAll(out, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(out, instructionsName), []byte("an earlier run"), 0o644); err != nil { + t.Fatal(err) + } + code, said := tfg("generate", writeOneTargetRecipe(t, dir, "purpose: A file for the guard.\n"), "--out", out) + if code != cli.ExitIO || !strings.Contains(said, instructionsName) { + t.Errorf("instructions already there ended %d rather than %d, saying:\n%s", code, cli.ExitIO, said) + } + if left := namesIn(t, out); len(left) != 1 { + t.Errorf("the refused run wrote into the directory anyway: %v", left) + } + + taken := filepath.Join(dir, "taken") + code, said = tfg("generate", writeOneTargetRecipe(t, dir, "purpose: A file for the guard.\n name: "+instructionsName+"\n"), "--out", taken) + if code == cli.ExitOK || !strings.Contains(said, "instructions beside its manifest") { + t.Errorf("a target named like the instructions ended %d, saying:\n%s", code, said) + } +} + +// A manifest naming anything but a plain name beside it as its instructions is +// not acted on, because cleanup --with-manifest removes that file. +func TestAManifestCannotSendCleanupAfterAFileOfSomebodyElses(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out") + generateFrom(t, dir, out, "purpose: A file for the guard.\n") + victim := filepath.Join(dir, "victim.instructions.md") + if err := os.WriteFile(victim, []byte("not the run's"), 0o644); err != nil { + t.Fatal(err) + } + path := filepath.Join(out, "manifest.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + doctored := bytes.Replace(raw, []byte(`"instructions": "`+instructionsName+`"`), []byte(`"instructions": "../victim.instructions.md"`), 1) + if bytes.Equal(doctored, raw) { + t.Fatal("the manifest carries no instructions field to change, so this guard asked nothing") + } + if err := os.WriteFile(path, doctored, 0o644); err != nil { + t.Fatal(err) + } + if code, _ := tfg("cleanup", path, "--yes", "--with-manifest"); code == cli.ExitOK { + t.Error("cleanup acted on a manifest that points its instructions outside the directory") + } + if _, err := os.Stat(victim); err != nil { + t.Error("cleanup removed a file outside the directory the manifest describes") + } +} + +// Instructions that cannot be written are said, the run stands, and nothing of +// them is left behind or named. +func TestInstructionsThatCannotBeWrittenAreSaidAndNotPretended(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out") + // A directory where the instructions are written first, under their + // temporary name, so the one write that is not a test file fails. + blocker := core.SiblingPath(filepath.Join(out, instructionsName), core.WritingMarker) + if err := os.MkdirAll(blocker, 0o755); err != nil { + t.Fatal(err) + } + code, said := tfg("generate", writeOneTargetRecipe(t, dir, "purpose: A file for the guard.\n"), "--out", out) + if code != cli.ExitOK { + t.Fatalf("the run failed over its instructions, which are words about files that were written: exit %d\n%s", code, said) + } + if !strings.Contains(said, "cannot write the instructions") { + t.Errorf("instructions that were not written went unsaid:\n%s", said) + } + if _, err := os.Stat(filepath.Join(out, instructionsName)); err == nil { + t.Error("the empty claim on the name of the instructions was left behind") + } + if got := recordedInstructions(t, out); got != "" { + t.Errorf("the manifest names instructions %q that were never written", got) + } +} + +// The preset screen offers the instructions its run wrote, and opens that file. +func TestTheWindowOpensTheInstructionsItsRunWrote(t *testing.T) { + dir := t.TempDir() + host, content := presetScreen(t) + choosePreset(t, content, "filename-handling") + fill(t, content, text.FieldOutputDir(), dir) + if shownButton(content, text.ButtonOpenInstructions()) != nil { + t.Fatal("the button is on the screen before anything was written") + } + press(t, content, text.ButtonGenerate()) + waitForManifest(t, host, dir) + join(host) + + button := shownButton(content, text.ButtonOpenInstructions()) + if button == nil { + t.Fatal("the run wrote instructions and the window offers no way to open them") + } + button.OnTapped() + if want := filepath.Join(dir, instructionsName); host.file != want || host.fileCount != 1 { + t.Errorf("the button opened %q (%d times), and the instructions are %q", host.file, host.fileCount, want) + } +} + +// generateFrom runs a one-target recipe into out, with extra lines under the +// target, and fails the guard if the run does not succeed. +func generateFrom(t *testing.T, dir, out, extra string) { + t.Helper() + if code, said := tfg("generate", writeOneTargetRecipe(t, dir, extra), "--out", out); code != cli.ExitOK { + t.Fatalf("the run ended %d:\n%s", code, said) + } +} + +// writeOneTargetRecipe writes a recipe of one small text file, with extra lines under +// the target, and gives its path. +func writeOneTargetRecipe(t *testing.T, dir, extra string) string { + t.Helper() + path := filepath.Join(dir, "recipe.yaml") + src := "version: 1\ntargets:\n - id: one\n format: txt\n size: 1kb\n" + if extra != "" { + src += " " + extra + } + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// tfg runs the command line and gives its exit code and everything it said. +func tfg(args ...string) (int, string) { + var out, errOut bytes.Buffer + code := cli.Run(context.Background(), args, &out, &errOut) + return code, out.String() + errOut.String() +} + +// recordedInstructions is what the manifest in dir names as its instructions. +func recordedInstructions(t *testing.T, dir string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, "manifest.json")) + if err != nil { + t.Fatalf("the run left no manifest: %v", err) + } + var m struct { + Run struct { + Instructions string `json:"instructions"` + } `json:"run"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m.Run.Instructions +} diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index fc2f051e..8be810f0 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -254,6 +254,8 @@ var reachableFromTheWindow = []string{ // no recipe at all. "recipe:targets", "recipe:targets.group", + // The box under the batch's manifest notes, since 2026-09-25. + "recipe:targets.purpose", "recipe:targets.boundary", "recipe:targets.contains", "recipe:targets.expected", diff --git a/internal/guard/presetbytes_test.go b/internal/guard/presetbytes_test.go index e51eb70d..215c4276 100644 --- a/internal/guard/presetbytes_test.go +++ b/internal/guard/presetbytes_test.go @@ -37,10 +37,18 @@ import ( // breaking change under D11 - a major, a Breaking entry in the changelog, and // the owner's decision, because untouchable rule 12 says the assistant does not // raise the version. A refactor that moved it is a refactor to undo. +// +// Decided once, on 2026-09-25: every sum here moved, because an ejected recipe +// now says what each target is for (the purpose key, which PR5 puts in the +// recipe rather than beside it). The owner's call was a Changed entry and no +// major - D11 is about the bytes of the files, and not one of them moved, +// which the guards on generated bytes hold. What did move is the recipe_hash +// of every run from a preset. docs/PRESET-INSTRUCTIONS-2026-09-25.md section 6. func TestEjectingAPresetGivesTheBytesItAlwaysGave(t *testing.T) { // bytes and sum are the whole document, refused the whole refusal. The // first row was measured 2026-09-08, the rest on 2026-09-24 before the - // change they guard. + // change they guard, and every sum again on 2026-09-25 when the purposes + // arrived - see above. pinned := []struct { id string args preset.Args @@ -48,30 +56,30 @@ func TestEjectingAPresetGivesTheBytesItAlwaysGave(t *testing.T) { sum string refused string }{ - {id: "size-boundaries", args: preset.Args{"format": "pdf", "limit": "10mb"}, bytes: 1298, sum: "2733cf63db40465fb97e26790d668d65ea01f5e94927a44ddf0869399beee2bb"}, + {id: "size-boundaries", args: preset.Args{"format": "pdf", "limit": "10mb"}, bytes: 2629, sum: "f0307b6325a227364fdcb81620d5029b1491447f7cc055aa99e06aa51aa33f54"}, {id: "size-boundaries", args: preset.Args{"format": "png", "limit": "1mb"}, refused: "the preset size-boundaries cannot build this set - under_1mb would be 0 B, and a file cannot be smaller than nothing. Raise the limit above 1048650 B, narrow the spread, or choose a format with a smaller minimum. The limit asked for was 1048576 B."}, - {id: "size-boundaries", args: preset.Args{"format": "jpg", "limit": "10mb"}, bytes: 1298, sum: "29c7e0a133fb97fdf9d19fb40d0d96ad97c4a1fef67556a9dc734b520f7d9b09"}, - {id: "size-boundaries", args: preset.Args{"format": "jpg", "limit": "2kb", "spread": "1kb"}, bytes: 640, sum: "1346f4a7ae514fe2d15de426b910e308442f66fa97b485932d8fa1f5df56b910"}, + {id: "size-boundaries", args: preset.Args{"format": "jpg", "limit": "10mb"}, bytes: 2489, sum: "6b8cd2e300de5f670a5c702e0145ef69d9965b9d43ed283943fbc3218efa8111"}, + {id: "size-boundaries", args: preset.Args{"format": "jpg", "limit": "2kb", "spread": "1kb"}, bytes: 1182, sum: "cbbb4f47bd0eca3846cf2c4f2f599a0c801287e361722798ce565ae3dd6c4b45"}, {id: "size-boundaries", args: preset.Args{"format": "jpg", "limit": "300", "spread": "100"}, refused: "the preset size-boundaries cannot build this set - under_100 would be 200 B and the smallest JPG this build makes is 602 B. Raise the limit above 702 B, narrow the spread, or choose a format with a smaller minimum. The limit asked for was 300 B."}, - {id: "upload-validation", args: preset.Args{}, bytes: 4116, sum: "a75039d859ee25d5ea5fd463ac2a774aacb1c45d013cf7ef3b80d46e0688fb78"}, - {id: "upload-validation", args: preset.Args{"limit": "5mb"}, bytes: 4108, sum: "4b7e716e2e199837b3c2bef228921e0087e894c49a8cf868a246c0ac636b9a35"}, + {id: "upload-validation", args: preset.Args{}, bytes: 8220, sum: "735f46d99eaf038ae050448a0277777e8511e2bea384506bc0299633c5cb4d9e"}, + {id: "upload-validation", args: preset.Args{"limit": "5mb"}, bytes: 8208, sum: "1d190c8e6a87b6ea09a8441759221cc3462b1baadaa09cb8a2809963ef5b55c3"}, {id: "upload-validation", args: preset.Args{"limit": "3kb"}, refused: "the preset upload-validation cannot build this set - allowed_pdf would be 1536 B and the smallest PDF this build makes is 3415 B. Raise the limit to 6830 B or more, or take pdf out of the allowed types. The limit asked for was 3072 B."}, - {id: "upload-validation", args: preset.Args{"allow": "docx,gif"}, bytes: 3816, sum: "33975195adf794dd9f6e96dbec8a9775bfbda3c4b7dfd3524475f70c39496059"}, - {id: "upload-validation", args: preset.Args{"allow": "xlsx,ico,wav"}, bytes: 4125, sum: "c46aba3ad1aa6bf3ac2442bde058b9802b4d4f72375fd2f7cbffc6fab1620d6f"}, - {id: "upload-validation", args: preset.Args{"bulk": "3", "far-over": "off"}, bytes: 3933, sum: "98bc51d5edb94a80fb764a03915eccc6997b70fa8eac153578320b3128cb6d95"}, - {id: "upload-validation", args: preset.Args{"deny": "exe,js"}, bytes: 3767, sum: "55d9946e7233761716849f52517cd58f7a38c8748f4cdae8620afade0681621c"}, - {id: "tabular-import", args: preset.Args{}, bytes: 3369, sum: "fad20b41756327a6e85da93715fb09b20db394fb23e4b3ce22bdaf6e11b20726"}, - {id: "tabular-import", args: preset.Args{"rows": "100"}, bytes: 3367, sum: "ee28ae5421d4717fb24ee6dfbef53f7a54e3a00105e5015f884d59768181d552"}, - {id: "tabular-import", args: preset.Args{"columns": "5"}, bytes: 3367, sum: "580d526546720b851ac7d834b97c163f5ff50b29caeff5b27fcc1ba00e5bb952"}, - {id: "text-encoding", args: preset.Args{}, bytes: 4570, sum: "de27b9dc6c646baebaa0b16019ba3ce15d0f1d145941d376b47263de242998e6"}, - {id: "text-encoding", args: preset.Args{"sample": "8kb"}, bytes: 4570, sum: "f87c73864e5f517abb08b50393cd9a1681a90a30560c1d14dfddcf31e8037479"}, - {id: "empty-and-minimal", args: preset.Args{}, bytes: 3811, sum: "80641962ac9dfb303f812fd78e0a0d1080f714094d159d7b10448291debb9279"}, - {id: "empty-and-minimal", args: preset.Args{"formats": "jpg,png,txt"}, bytes: 743, sum: "4fd23e4b06a2e27ede987ab48a2cc302cc2accb9f948c7d5d25f675681f31f69"}, + {id: "upload-validation", args: preset.Args{"allow": "docx,gif"}, bytes: 7618, sum: "1c4ffcc64e820f8384b98bc2b279df4fc0b16163e18f056fd9210f99fe879b1c"}, + {id: "upload-validation", args: preset.Args{"allow": "xlsx,ico,wav"}, bytes: 8254, sum: "5c2326e18ac1fe3c076a2391eed6f4819de60ec3726db2dc052872f1195dace0"}, + {id: "upload-validation", args: preset.Args{"bulk": "3", "far-over": "off"}, bytes: 7858, sum: "51b3271ed9c474af8f3c94ae9b38b82bf49b2bbcbfd67d6988259683df6f9024"}, + {id: "upload-validation", args: preset.Args{"deny": "exe,js"}, bytes: 7622, sum: "237f17d9907ce228c7928de6362ef6e47ef9d0c14467a10e5d2e7cfc247a7233"}, + {id: "tabular-import", args: preset.Args{}, bytes: 6045, sum: "e3d5ef9cc2633d05654b4bee16d62b61a30a776ff7a38cbdf35b3b073e18972e"}, + {id: "tabular-import", args: preset.Args{"rows": "100"}, bytes: 6042, sum: "7187e6a619e46a6f376361af433a45f9f4035c06f185c3cce7836179a3d4c2c0"}, + {id: "tabular-import", args: preset.Args{"columns": "5"}, bytes: 6042, sum: "81b712fe1186372ffdbf0d7c92ca753e5fac0f9083bb101695c4f0d37d8d4644"}, + {id: "text-encoding", args: preset.Args{}, bytes: 9263, sum: "322c2e02b446b0280555728f8401156d1569cd3046e57dc3af54bc0a2fde28aa"}, + {id: "text-encoding", args: preset.Args{"sample": "8kb"}, bytes: 9263, sum: "897a487c6d9eebaa1ff7d9bae44f17aec4bfcbd30597586f0b7b22eadb473ecd"}, + {id: "empty-and-minimal", args: preset.Args{}, bytes: 8966, sum: "3e815fffad3da06d1d8333111ae2ccaa1d325172d87c6d49e2177249808083fe"}, + {id: "empty-and-minimal", args: preset.Args{"formats": "jpg,png,txt"}, bytes: 1472, sum: "19b12800372395ad3bf5cba6407ca654d7ed51eba3a5bc5436f76a94bc09253c"}, // The preset of unusual file names, measured 2026-09-25 on its first // build: the default, and a format whose extension is a byte longer, // since the names about length are made to a length with it. - {id: "filename-handling", args: preset.Args{}, bytes: 10324, sum: "fc051b2285c0efed30bc5e19920e6f08e25fc8f95e3b1be0ed8d228a25310e43"}, - {id: "filename-handling", args: preset.Args{"format": "docx"}, bytes: 10418, sum: "65fcae1471cd3b0111cae3dba14cd7d3d39beb6f998dc665b3dbaec898aade86"}, + {id: "filename-handling", args: preset.Args{}, bytes: 18983, sum: "f720c4a54c5fc17fea553a084fd13aaff4150c2fce5a4d953b10aee137ebe021"}, + {id: "filename-handling", args: preset.Args{"format": "docx"}, bytes: 19077, sum: "94a40d97bf549cbdd3e7ccaafe3372d7962ad542f358d37a2d945445c95d6f43"}, } for _, want := range pinned { diff --git a/internal/guard/presetcommands_test.go b/internal/guard/presetcommands_test.go index 6ce79d50..c679441d 100644 --- a/internal/guard/presetcommands_test.go +++ b/internal/guard/presetcommands_test.go @@ -70,7 +70,7 @@ func TestTheBudgetShownIsTheBudgetWritten(t *testing.T) { t.Fatal(err) } for _, e := range entries { - if e.Name() == "manifest.json" { + if isRecord(e.Name()) { continue } info, err := e.Info() @@ -142,6 +142,19 @@ func TestEjectingAPresetAndRunningItGivesTheSameRunBackDefaultsIncluded(t *testi continue } a, err := os.ReadFile(filepath.Join(fromPreset, e.Name())) + if err == nil && isRecord(e.Name()) { + // The instructions open by naming the preset, which only the run + // from the preset knows - the manifest differs the same way, in + // run.preset. Everything after the opening is the files, and that + // part has to agree: it is what shows the purposes went through + // eject rather than staying behind in the preset (PR5). + b, readErr := os.ReadFile(filepath.Join(fromFile, e.Name())) + if readErr != nil || !bytes.Equal(afterOpening(a), afterOpening(b)) || len(afterOpening(a)) == 0 { + t.Errorf("the instructions of the preset and of the recipe ejected from it say different things about the files (read: %v)", readErr) + } + compared++ + continue + } if err != nil { t.Fatal(err) } @@ -464,9 +477,9 @@ func TestReadingThePresetOutOfTheArgumentsHandlesBothSpellings(t *testing.T) { if err != nil { t.Fatal(err) } - // Seven files of the set plus the manifest. - if len(entries) != 8 { - t.Errorf("wrote %d entries and the set is seven files and a manifest", len(entries)) + // Seven files of the set, the manifest and its instructions. + if len(entries) != 9 { + t.Errorf("wrote %d entries and the set is seven files, a manifest and its instructions", len(entries)) } }) } @@ -526,3 +539,13 @@ func TestAPresetParameterBecomesAFlagAndAnUnknownOneDoesNot(t *testing.T) { t.Errorf("a parameter nobody declared ended with %d rather than %d", code, cli.ExitUsage) } } + +// afterOpening is the instructions from their first section on - the files, +// without the opening that names where the run came from. +func afterOpening(md []byte) []byte { + at := bytes.Index(md, []byte("\n## ")) + if at < 0 { + return nil + } + return md[at:] +} diff --git a/internal/guard/presetwindow_test.go b/internal/guard/presetwindow_test.go index 8fa955e6..77d71447 100644 --- a/internal/guard/presetwindow_test.go +++ b/internal/guard/presetwindow_test.go @@ -82,8 +82,8 @@ func TestThePresetScreenBuildsTheMinimalSetTheCommandLineBuilds(t *testing.T) { // 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", + if len(cliNames) != wanted+2 { + t.Fatalf("the preset produced %d thing(s) and %d files, a manifest and its instructions were expected: %v", len(cliNames), wanted, cliNames) } @@ -92,7 +92,12 @@ func TestThePresetScreenBuildsTheMinimalSetTheCommandLineBuilds(t *testing.T) { if name == "manifest.json" { continue } - compared++ + // The instructions are compared byte for byte like the files - both + // surfaces write them through one function and from one manifest - but + // counted apart, because wanted is the size of the set. + if !isRecord(name) { + compared++ + } a, err := os.ReadFile(filepath.Join(fromCLI, name)) if err != nil { t.Fatalf("reading %s from the command line run: %v", name, err) @@ -261,11 +266,11 @@ func TestThePresetScreenAndTheCommandLineProduceTheSameRun(t *testing.T) { // guard from catching 30 injected faults out of 30 down to 13, with the // mutation report still looking clean. // - // size-boundaries is seven files plus the manifest, and asserting the count - // here rather than logging it is the whole difference. + // size-boundaries is seven files, the manifest and its instructions, and + // asserting the count here rather than logging it is the whole difference. const wanted = 7 - if len(cliNames) != wanted+1 { - t.Fatalf("the preset produced %d thing(s) and %d files plus a manifest was expected: %v", + if len(cliNames) != wanted+2 { + t.Fatalf("the preset produced %d thing(s) and %d files, a manifest and its instructions were expected: %v", len(cliNames), wanted, cliNames) } @@ -277,7 +282,12 @@ func TestThePresetScreenAndTheCommandLineProduceTheSameRun(t *testing.T) { if name == "manifest.json" { continue } - compared++ + // The instructions are compared byte for byte like the files - both + // surfaces write them through one function and from one manifest - but + // counted apart, because wanted is the size of the set. + if !isRecord(name) { + compared++ + } a, err := os.ReadFile(filepath.Join(fromCLI, name)) if err != nil { t.Fatalf("reading %s from the command line run: %v", name, err) diff --git a/internal/guard/recipeshapes_test.go b/internal/guard/recipeshapes_test.go index d08911df..4e2ca415 100644 --- a/internal/guard/recipeshapes_test.go +++ b/internal/guard/recipeshapes_test.go @@ -133,6 +133,7 @@ var shapeCases = map[string]string{ "format": "version: 1\ntargets:\n - id: a\n format: {a: b}\n size: 1kb\noutput:\n dir: ./o\n", "name": "version: 1\ntargets:\n - id: a\n format: txt\n size: 1kb\n name: {a: b}\noutput:\n dir: ./o\n", "group": "version: 1\ntargets:\n - id: a\n format: txt\n size: 1kb\n group: {a: b}\noutput:\n dir: ./o\n", + "purpose": "version: 1\ntargets:\n - id: a\n format: txt\n size: 1kb\n purpose: {a: b}\noutput:\n dir: ./o\n", "count": "version: 1\ntargets:\n - id: a\n format: txt\n size: 1kb\n count: {a: b}\noutput:\n dir: ./o\n", "size": "version: 1\ntargets:\n - id: a\n format: txt\n size: {a: b}\noutput:\n dir: ./o\n", "size-range": "version: 1\ntargets:\n - id: a\n format: txt\n size-range: {a: b}\noutput:\n dir: ./o\n", diff --git a/internal/guard/settingslot_test.go b/internal/guard/settingslot_test.go index 01e5c42a..8861f06c 100644 --- a/internal/guard/settingslot_test.go +++ b/internal/guard/settingslot_test.go @@ -185,14 +185,14 @@ 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", "formats": "a", + "damage": "a", "bytes": "a", "formats": "a", "purpose": "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. "start_from_preset": "a", "extends": "an", "with": "a", // Labels, which is what a window shows. "Batch name": "a", "How many files": "a", "File names": "a", "Size": "a", - "Damage": "a", + "Damage": "a", "Purpose": "a", "Format": "a", "Seed": "a", "Output directory": "an", "Kind of case": "a", "Around a limit": "an", "Size range": "a", "Expected outcome": "an", "Limit to test": "a", "One size": "a", "A range": "a", diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index fd287d1b..c0803613 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -39,6 +39,10 @@ "description": "The words on a button.", "other": "Open folder" }, + "ButtonOpenInstructions": { + "description": "The words on a button.", + "other": "Open instructions" + }, "ButtonOpenManifest": { "description": "The words on a button.", "other": "Open manifest" @@ -115,6 +119,10 @@ "description": "The longer explanation behind the button beside a field name.", "other": "The set is worked out from the answer." }, + "DetailPurpose": { + "description": "The longer explanation behind the button beside a field name.", + "other": "It is written into the manifest and into the instructions beside it, which say what every file of the run is for. It changes no byte of any file." + }, "DetailReason": { "description": "The longer explanation behind the button beside a field name.", "other": "From a closed list, so a report can group by reason. It names the rule in play whatever the outcome is - a file a byte under a size limit is expected to be accepted, and the rule in play is still the size limit. A reason needs an outcome beside it." @@ -195,6 +203,10 @@ "description": "The name above a box somebody fills in.", "other": "Preset" }, + "FieldPurpose": { + "description": "The name above a box somebody fills in.", + "other": "Purpose" + }, "FieldReason": { "description": "The name above a box somebody fills in.", "other": "Rule being tested" @@ -269,6 +281,10 @@ "description": "The line under a field name, saying what the field does.", "other": "What you are testing." }, + "HintPurpose": { + "description": "The line under a field name, saying what the field does.", + "other": "What these files are and why they are in the set." + }, "HintReason": { "description": "The line under a field name, saying what the field does.", "other": "Which rule this is about." @@ -305,6 +321,10 @@ "description": "Shown in the window.", "other": "Point your test at the manifest. For every file it says what the system under test should do with it - accept it, reject it or sanitize it - or records the outcome as unspecified, where the right answer belongs to the application's own policy." }, + "InstructionsNotSaved": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Path}}, {{.Cause}}.", + "other": "The instructions could not be saved to {{.Path}} ({{.Cause}}). The files and the manifest are complete, and the manifest holds the same facts." + }, "ListHeadingCount": { "description": "Shown inside an open list somebody chooses from. Carries these values, each of which has to stay spelled exactly that way: {{.Kind}}, {{.Count}}.", "other": "{{.Kind}} · {{.Count}}" diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index 6f7e5e79..2a258095 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -483,6 +483,7 @@ func DetailSizeWay() string { return say("DetailSizeWay", "One size gives every file the same size. A range draws a different size for each file. Around a limit makes three files: one byte under the limit, one on it, one over.") } func FieldGroup() string { return say("FieldGroup", "Kind of case") } +func FieldPurpose() string { return say("FieldPurpose", "Purpose") } func FieldExpected() string { return say("FieldExpected", "Expected outcome") } func FieldReason() string { return say("FieldReason", "Rule being tested") } func FieldManifest() string { return say("FieldManifest", "Manifest file name") } @@ -493,6 +494,9 @@ func HintBoundary() string { return say("HintBoundary", "Three files: one byte under the limit, one on it, one over.") } func HintGroup() string { return say("HintGroup", "Marks several batches as one kind of case.") } +func HintPurpose() string { + return say("HintPurpose", "What these files are and why they are in the set.") +} func HintExpected() string { return say("HintExpected", "What the system under test should do with these files.") } @@ -506,6 +510,9 @@ func DetailSizeRange() string { func DetailBoundary() string { return say("DetailBoundary", "Give the limit your system declares, as 10mb. Units count in 1024s, and the run prints the number it used.") } +func DetailPurpose() string { + return say("DetailPurpose", "It is written into the manifest and into the instructions beside it, which say what every file of the run is for. It changes no byte of any file.") +} func DetailGroup() string { return say("DetailGroup", "It reaches the manifest, so a test can assert about a whole class of case at once.") } diff --git a/internal/gui/text/text.go b/internal/gui/text/text.go index 7c9953e6..2c5e9080 100644 --- a/internal/gui/text/text.go +++ b/internal/gui/text/text.go @@ -62,6 +62,12 @@ func ButtonOpenFolder() string { return say("ButtonOpenFolder", "Open folder") } func ButtonOpenManifest() string { return say("ButtonOpenManifest", "Open manifest") } func ButtonCancel() string { return say("ButtonCancel", "Cancel") } +// ButtonOpenInstructions opens the instructions a finished run wrote beside +// its manifest - what every file is for, in words. On the bar beside the +// manifest's button, and only while there are some: a run nobody explained +// writes none. +func ButtonOpenInstructions() string { return say("ButtonOpenInstructions", "Open instructions") } + // files is a count with its noun, in the right number. // // The doc above this package has described "file(s)" as a dodge since the day @@ -400,6 +406,14 @@ func ManifestNotSaved(path string) string { map[string]any{"Path": path}) } +// InstructionsNotSaved is said when the instructions of a run could not be +// written. Every file and the manifest are whole, so it is said, not refused. +func InstructionsNotSaved(path, cause string) string { + return sayf("InstructionsNotSaved", + "The instructions could not be saved to {{.Path}} ({{.Cause}}). The files and the manifest are complete, and the manifest holds the same facts.", + map[string]any{"Path": path, "Cause": cause}) +} + // NothingProduced is the outcome when a run ended with no manifest at all. func NothingProduced() string { return say("NothingProduced", "Nothing was produced.") } diff --git a/internal/gui/window/preset.go b/internal/gui/window/preset.go index aa0dc623..8b1e4b6b 100644 --- a/internal/gui/window/preset.go +++ b/internal/gui/window/preset.go @@ -326,6 +326,7 @@ func (p *Preset) settle() ([]engine.Target, engine.Options, error) { // notice the day something did, so the copy is the whole defence. Preset: &manifest.Preset{ ID: expanded.Preset.ID, + Question: expanded.Preset.Question, Parameters: maps.Clone(map[string]string(expanded.Settled)), Defaulted: slices.Clone(expanded.Defaulted), }, @@ -400,6 +401,7 @@ func engineTarget(t recipe.Target) engine.Target { Expected: t.Expected, ExpectedReason: t.ExpectedReason, Group: t.Group, + Purpose: t.Purpose, Properties: t.Properties, } } diff --git a/internal/gui/window/recipe.go b/internal/gui/window/recipe.go index e47b7b9e..5714d7ba 100644 --- a/internal/gui/window/recipe.go +++ b/internal/gui/window/recipe.go @@ -131,13 +131,10 @@ type batch struct { // section is rebuilt whenever the format changes, so a fold living in it // would spring open every time somebody looked at another format. settingsFolded bool - notesFolded bool settings *parts.Folding - notes *parts.Folding name *parts.Entry - group *parts.Entry - expected *parts.Chooser - reason *parts.Chooser + // notes is the other section, with its fold, as one piece - see batchNotes. + notes batchNotes // declared is what the chosen format takes, and props are the controls drawn // from it. Both are replaced when the format changes and reused across a @@ -266,13 +263,12 @@ func (r *Recipe) newBatch() *batch { sizeRange: parts.NewEntry(), boundary: parts.NewEntry(), name: parts.NewEntry(), - group: parts.NewEntry(), + notes: newBatchNotes(), sizeWay: newSizeWaySwitch(), // Both sections arrive put away. The owner's decision of 2026-08-25, // and the number under it is 248 px of form per screen (O98) for // settings a format works out on its own when nobody states them. settingsFolded: true, - notesFolded: true, } b.name.SetPlaceHolder(text.PlaceholderNameTemplate) // What happens if the box is left alone, in the place a box says that. @@ -288,22 +284,6 @@ func (r *Recipe) newBatch() *batch { // with a default and a setting without: a batch with no id is refused // rather than filled in, because an id is what anchors a batch's seed. b.count.SetPlaceHolder(text.PlaceholderLeftEmpty(strconv.Itoa(recipe.DefaultCount))) - // The class is optional metadata, so it says so the same way. It stood - // empty and silent beside the id above it, which is REFUSED when empty - - // two boxes side by side, one you must fill in and one you need not, - // drawn identically. The rule this closes is worth more than the two - // fields: a box with a hint may be left alone, a box with nothing in it - // may not, and TestABoxYouMayLeaveAloneSaysSo holds it from the registry. - b.group.SetPlaceHolder(text.PlaceholderNotStated()) - - // Nothing is filled in with a default, on either list. A list carrying a - // value cannot say "I did not state this", and an expectation nobody stated - // has to stay unstated - manifest rule MF5, because an invented expectation - // produces false failures in somebody else's test run. - b.expected = parts.NewChooser(recipe.Outcomes(), nil) - b.expected.PlaceHolder = text.PlaceholderNotStated() - b.reason = parts.NewChooser(recipe.Reasons(), nil) - b.reason.PlaceHolder = text.PlaceholderNotStated() b.formatPick = parts.NewChooser(format.IDs(), func(id string) { r.onFormatChosen(b, id) @@ -641,9 +621,7 @@ func (r *Recipe) duplicateBatch(index int) { to.sizeRange.SetText(from.sizeRange.Text) to.boundary.SetText(from.boundary.Text) to.name.SetText(from.name.Text) - to.group.SetText(from.group.Text) - to.expected.SetSelected(from.expected.Selected) - to.reason.SetSelected(from.reason.Selected) + to.notes.takeFrom(from.notes) if from.sizeWay != nil && to.sizeWay != nil { to.sizeWay.SetSelected(from.sizeWay.Selected) } @@ -752,6 +730,7 @@ func (r *Recipe) settle() ([]engine.Target, engine.Options, error) { if e := read.Expansion; e != nil { opt.Preset = &manifest.Preset{ ID: e.Preset.ID, + Question: e.Preset.Question, Parameters: map[string]string(e.Settled), Defaulted: e.Defaulted, } @@ -827,9 +806,10 @@ func (b *batch) draft() recipe.TargetDraft { SizeRange: b.statedSize(recipe.KeySizeRange), Boundary: b.statedSize(recipe.KeyBoundary), Name: b.name.Text, - Group: b.group.Text, - Expected: b.expected.Selected, - ExpectedReason: b.reason.Selected, + Group: b.notes.group.Text, + Purpose: b.notes.purpose.Text, + Expected: b.notes.expected.Selected, + ExpectedReason: b.notes.reason.Selected, Properties: props, Contains: inside, } diff --git a/internal/gui/window/recipefolds.go b/internal/gui/window/recipefolds.go index 8cb9155d..c5b91d16 100644 --- a/internal/gui/window/recipefolds.go +++ b/internal/gui/window/recipefolds.go @@ -83,17 +83,71 @@ func (r *Recipe) declaredSettings(b *batch, at func(string) string) fyne.CanvasO // rule at all. TestNothingInTheManifestNotesChangesAByte holds the line against // the engine rather than against this comment. func (r *Recipe) manifestNotes(b *batch, add addField) fyne.CanvasObject { - b.notes = parts.NewInnerFolding(text.SectionManifestNotes(), + n := &b.notes + n.fold = parts.NewInnerFolding(text.SectionManifestNotes(), parts.Note(text.NoteManifestOnly()), add(recipe.KeyGroup, text.FieldGroup(), text.HintGroup(), - r.tips.Say(text.DetailGroup()), parts.Text(b.group)), + r.tips.Say(text.DetailGroup()), parts.Text(n.group)), + // Across the row, because it is a sentence rather than a name. It is + // what the instructions beside the manifest say about these files. + parts.Wide(add(recipe.KeyPurpose, text.FieldPurpose(), text.HintPurpose(), + r.tips.Say(text.DetailPurpose()), n.purpose)), add(recipe.KeyExpected, text.FieldExpected(), text.HintExpected(), - r.tips.Say(text.DetailExpected()), b.expected), + r.tips.Say(text.DetailExpected()), n.expected), add(recipe.KeyExpectedReason, text.FieldReason(), text.HintReason(), - r.tips.Say(text.DetailReason()), b.reason), + r.tips.Say(text.DetailReason()), n.reason), ) - r.wire(b.notes, &b.notesFolded, func() string { return b.notesSaid() }) - return b.notes.Object() + r.wire(n.fold, &n.folded, n.said) + return n.fold.Object() +} + +// batchNotes is the manifest notes of one batch: what the manifest says about +// its files and nothing that changes a byte of them - the kind of case, what +// the files are for, the outcome expected and the rule it is about - with the +// section they are drawn in. +// +// Its own type since 2026-09-25, when the purpose took batch one field past +// its ceiling. The ceiling is a ratchet, so the answer was to move state out, +// and this was the seam: one section on the screen, one rule about what may be +// in it (TestNothingInTheManifestNotesChangesAByte), and nothing the rest of +// the batch reads. +type batchNotes struct { + group, purpose *parts.Entry + expected, reason *parts.Chooser + fold *parts.Folding + // folded survives a rebuild for the reason batch.settingsFolded gives, and + // starts true: the section arrives put away. + folded bool +} + +func newBatchNotes() batchNotes { + n := batchNotes{group: parts.NewEntry(), purpose: parts.NewEntry(), folded: true} + // The class is optional metadata, so it says so the way the count does. + // It stood empty and silent beside the id, which is REFUSED when empty - + // two boxes side by side, one you must fill in and one you need not, + // drawn identically. The rule this closes is worth more than the two + // fields: a box with a hint may be left alone, a box with nothing in it + // may not, and TestABoxYouMayLeaveAloneSaysSo holds it from the registry. + n.group.SetPlaceHolder(text.PlaceholderNotStated()) + n.purpose.SetPlaceHolder(text.PlaceholderNotStated()) + + // Nothing is filled in with a default, on either list. A list carrying a + // value cannot say "I did not state this", and an expectation nobody stated + // has to stay unstated - manifest rule MF5, because an invented expectation + // produces false failures in somebody else's test run. + n.expected = parts.NewChooser(recipe.Outcomes(), nil) + n.expected.PlaceHolder = text.PlaceholderNotStated() + n.reason = parts.NewChooser(recipe.Reasons(), nil) + n.reason.PlaceHolder = text.PlaceholderNotStated() + return n +} + +// takeFrom copies what another batch's notes hold, for a copied batch. +func (n batchNotes) takeFrom(from batchNotes) { + n.group.SetText(from.group.Text) + n.purpose.SetText(from.purpose.Text) + n.expected.SetSelected(from.expected.Selected) + n.reason.SetSelected(from.reason.Selected) } // wire puts a section into the state it was left in and keeps it there across @@ -140,9 +194,9 @@ func (b *batch) settingsSaid() string { return text.FoldedSummary(said...) } -// notesSaid is what the manifest notes say while they are away. -func (b *batch) notesSaid() string { - return text.FoldedSummary(b.group.Text, b.expected.Selected, b.reason.Selected) +// said is what the manifest notes say while they are away. +func (n *batchNotes) said() string { + return text.FoldedSummary(n.group.Text, n.purpose.Text, n.expected.Selected, n.reason.Selected) } // openFoldHolding opens everything a box has been put away inside. @@ -174,7 +228,7 @@ func (r *Recipe) openFoldHolding(address string) { return } for _, b := range r.batches { - for _, fold := range []*parts.Folding{b.fold, b.settings, b.notes} { + for _, fold := range []*parts.Folding{b.fold, b.settings, b.notes.fold} { if fold != nil && fold.Holds(field.Control) { fold.Set(true) } diff --git a/internal/gui/window/run.go b/internal/gui/window/run.go index a353c592..db694d79 100644 --- a/internal/gui/window/run.go +++ b/internal/gui/window/run.go @@ -665,7 +665,7 @@ func (r *runner) startRun(targets []engine.Target, opt engine.Options) { // The manifest is written here rather than after crossing back, because // it is disk work and the interface thread is the one thing that must // not wait on a disk. - savedTo, saveErr := saveManifest(res, opt) + saved, saveErr := saveRecord(res, opt) // The room left on the disk is the room left AFTER the files, which // is not the number a preview measured before them. room := roomOn(opt.OutDir) @@ -673,7 +673,7 @@ func (r *runner) startRun(targets []engine.Target, opt engine.Options) { // stop, waiting on the channel closed below, and a worker waiting for // that thread to run something would be both of them waiting. r.holdBeforeFinishing() - fyne.Do(func() { r.runFinished(res, runErr, saveErr, room, savedTo) }) + fyne.Do(func() { r.runFinished(res, runErr, saveErr, room, saved) }) close(done) }() } @@ -683,10 +683,10 @@ func (r *runner) startRun(targets []engine.Target, opt engine.Options) { // Note what it does not do: clear stop. That is deliberate and the reason is at // the declaration of the field. // -// savedTo is where the record went, or nothing at all when no record was +// saved is where the record went, with nothing in it when no record was // written - which is a refused run, a preview, and a run whose manifest could // not be saved. The screen says nothing about a manifest in any of those. -func (r *runner) runFinished(res *engine.Result, runErr, saveErr error, room diskRoom, savedTo string) { +func (r *runner) runFinished(res *engine.Result, runErr, saveErr error, room diskRoom, saved engine.Record) { r.busy.set(false, busyFace{}) if room.known && r.offer.wroteInto != "" { r.line.measured(r.offer.wroteInto, room.free) @@ -715,12 +715,19 @@ func (r *runner) runFinished(res *engine.Result, runErr, saveErr error, room dis // command line has printed it since there was a manifest, and the window // said only how many files - so the one thing this tool makes that others // do not was, from a window, something you found in the folder afterwards. - outcome := text.SaidWithManifest(outcomeText(res, runErr), manifestNameOf(savedTo)) + outcome := text.SaidWithManifest(outcomeText(res, runErr), manifestNameOf(saved.Manifest)) said := append([]string{outcome}, manifestReachNote(res)...) + // Instructions that were due and are not there are said, not failed: every + // file and the manifest are whole. Silence would leave a button missing + // with no reason given. + if saved.Missed != nil { + said = append(said, text.InstructionsNotSaved(saved.Missed.Path, saved.Missed.Err.Error())) + } r.say(append(said, notesOf(res)...)...) r.toneOfOutcome(res, runErr) r.offer.theFolder(res) - r.offer.theManifest(savedTo) + r.offer.manifest.show(saved.Manifest) + r.offer.instructions.show(saved.Instructions) } // manifestNameOf is the file's own name, for a sentence that stands beside a diff --git a/internal/gui/window/runactions.go b/internal/gui/window/runactions.go index b4e1a872..e6e7aede 100644 --- a/internal/gui/window/runactions.go +++ b/internal/gui/window/runactions.go @@ -79,6 +79,6 @@ func (r *runner) actions() fyne.CanvasObject { // A row of ours with a wider gap since the prototype of 2026-09-23 - see // parts.ButtonRow. r.busy.row = parts.ButtonRow(r.previewBtn, r.generateBtn, r.busy.cancel, - r.offer.folderBtn, r.offer.manifestBtn) + r.offer.folderBtn, r.offer.manifest.btn, r.offer.instructions.btn) return r.busy.row } diff --git a/internal/gui/window/runoffers.go b/internal/gui/window/runoffers.go index fcbe632f..5f753735 100644 --- a/internal/gui/window/runoffers.go +++ b/internal/gui/window/runoffers.go @@ -26,8 +26,12 @@ import ( // being there, because somebody would open it, see the old files and believe // them. type offers struct { - folderBtn *parts.Button - manifestBtn *parts.Button + folderBtn *parts.Button + // manifest and instructions lead to the two files a run writes about + // itself. One type for both since 2026-09-25, when the instructions came + // and the second button leading to a file would have been a copy of the + // first. + manifest, instructions *fileOffer // openFolder and openFile are how this asks the desktop, held as // functions rather than reaching for the host: the runner is shared by @@ -36,12 +40,11 @@ type offers struct { openFolder func(string) openFile func(string) - // wroteInto and wroteManifest are where the run that just finished put - // its files and its record. Kept rather than read back off the form, - // because the boxes on the screen can be edited afterwards and a button - // has to lead where the run ACTUALLY went. - wroteInto string - wroteManifest string + // wroteInto is where the run that just finished put its files. Kept + // rather than read back off the form, because the boxes on the screen can + // be edited afterwards and a button has to lead where the run ACTUALLY + // went. The two files keep their paths in their own offers. + wroteInto string // relay redraws the bar when a button appears or goes, because the // toolkit does not lay the row out again by itself when a child of it is @@ -58,13 +61,30 @@ func newOffers(relay func()) *offers { } }) o.folderBtn.InTheBar().Hide() - o.manifestBtn = parts.NewButton(parts.Secondary, text.ButtonOpenManifest(), func() { - if o.wroteManifest != "" && o.openFile != nil { - o.openFile(o.wroteManifest) + o.manifest = newFileOffer(o, text.ButtonOpenManifest()) + o.instructions = newFileOffer(o, text.ButtonOpenInstructions()) + return o +} + +// fileOffer is one button leading to one file a finished run wrote about +// itself, built hidden and shown once the file is there. +type fileOffer struct { + btn *parts.Button + // path is the file the button opens. Kept rather than worked out again, + // for the reason wroteInto gives. + path string + of *offers +} + +func newFileOffer(of *offers, label string) *fileOffer { + f := &fileOffer{of: of} + f.btn = parts.NewButton(parts.Secondary, label, func() { + if f.path != "" && of.openFile != nil { + of.openFile(f.path) } }) - o.manifestBtn.InTheBar().Hide() - return o + f.btn.InTheBar().Hide() + return f } // through says which desktop these buttons reach. Called by every screen as @@ -91,24 +111,32 @@ func (o *offers) theFolder(res *engine.Result) { o.relay() } -// theManifest shows the way to the record, once there is one. +// show offers the way to a file, once there is one. // // Asked about the SAVING rather than about the run, which is the difference // from the folder above: a run that wrote files and could not save its // manifest has a folder worth opening and no record to open. The screen // refuses about that in its own sentence, and a button pointing at the file -// that refusal is about would be the screen disagreeing with itself. +// that refusal is about would be the screen disagreeing with itself. The +// instructions the same way: a run nobody explained writes none, and one that +// could not write them says so. // // The path is the one saving used rather than one worked out again here. The // manifest's name is a field on the batch screen, so a second way of arriving // at it is a second chance to name a different file. -func (o *offers) theManifest(path string) { +func (f *fileOffer) show(path string) { if path == "" { return } - o.wroteManifest = path - o.manifestBtn.Show() - o.relay() + f.path = path + f.btn.Show() + f.of.relay() +} + +// hide takes the offer away, for the next run. +func (f *fileOffer) hide() { + f.path = "" + f.btn.Hide() } // inTheWay is the directory a refusal is about when it is about something @@ -143,7 +171,7 @@ func inTheWay(err error) string { func (o *offers) forget() { o.wroteInto = "" o.folderBtn.Hide() - o.wroteManifest = "" - o.manifestBtn.Hide() + o.manifest.hide() + o.instructions.hide() o.relay() } diff --git a/internal/gui/window/runreport.go b/internal/gui/window/runreport.go index fa50ffc9..a3eb83e7 100644 --- a/internal/gui/window/runreport.go +++ b/internal/gui/window/runreport.go @@ -66,7 +66,7 @@ func progressText(p engine.Progress, elapsed time.Duration) string { return line + text.TimeLeft(core.Roughly(left)) } -// saveManifest writes the record of what the run did, and hands back where it +// saveRecord writes the record of what the run did, and hands back where it // put it. // // A run refused before it wrote anything gets none. Writing one would replace @@ -79,9 +79,12 @@ func progressText(p engine.Progress, elapsed time.Duration) string { // constant - and a screen that says one name while the file has another is // worse than a screen that says nothing. An empty path means no record was // written, which is what the button and the sentence both ask about. -func saveManifest(res *engine.Result, opt engine.Options) (string, error) { +// +// The instructions beside the manifest are saved with it, by the function the +// command line calls, so both surfaces leave the same directory behind. +func saveRecord(res *engine.Result, opt engine.Options) (engine.Record, error) { if opt.DryRun || res == nil || !res.Started { - return "", nil + return engine.Record{}, nil } // Asked of the engine rather than joined here. This used to be // filepath.Join(opt.OutDir, opt.ManifestName), which is the same answer @@ -89,9 +92,9 @@ func saveManifest(res *engine.Result, opt engine.Options) (string, error) { // directory itself, so saving would have tried to rename a file onto a // directory. All three screens do fill it in, which is why nothing ever // reached it. - path := engine.ManifestPath(opt) - if err := res.Manifest.Save(path); err != nil { - return "", fmt.Errorf("%s: %w", text.ManifestNotSaved(path), err) + saved, err := engine.SaveRecord(res, opt) + if err != nil { + return engine.Record{}, fmt.Errorf("%s: %w", text.ManifestNotSaved(saved.Manifest), err) } - return path, nil + return saved, nil } diff --git a/internal/manifest/instructions.go b/internal/manifest/instructions.go new file mode 100644 index 00000000..abc99888 --- /dev/null +++ b/internal/manifest/instructions.go @@ -0,0 +1,267 @@ +package manifest + +import ( + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// instructionsSuffix is what the name of the instructions ends in. The rest of +// the name is the manifest's, so the two stand side by side in any listing +// and two runs recorded under two manifests in one directory never meet. +const instructionsSuffix = ".instructions.md" + +// InstructionsName is the name of the instructions written beside a manifest +// of the given name: manifest.json gives manifest.instructions.md, and a name +// that does not end in .json keeps all of itself. +func InstructionsName(manifestName string) string { + stem := manifestName + if ext := filepath.Ext(stem); strings.EqualFold(ext, ".json") { + stem = strings.TrimSuffix(stem, ext) + } + return stem + instructionsSuffix +} + +// isInstructionsName says whether a name read from a manifest can be the +// instructions beside it: a plain file name, no directory in it, ending the +// way InstructionsName ends one. +func isInstructionsName(name string) bool { + return strings.HasSuffix(name, instructionsSuffix) && + !strings.ContainsAny(name, `/\:`) && core.ContainmentProblem(name) == "" +} + +// Explains says whether any file of the run was given a purpose, which is +// when the run writes instructions at all. A run nobody explained - a single +// format from the command line - has nothing to say beyond the manifest. +func (m *Manifest) Explains() bool { + for _, f := range m.Files { + if f.Purpose != "" { + return true + } + } + return false +} + +// Instructions is the manifest said in words for the person who opens the +// directory: what each file is, why it is in the set and what their system is +// expected to do with it. Nil when no file was given a purpose. +// +// Written from the manifest alone, so the command line and the window write +// the same bytes for the same run, and nothing in it depends on when or where +// the run happened - no time, no command line, no directory. The manifest is +// named rather than linked, because the reader may be looking at the file on +// its own. +// +// A list rather than a table. A file name here can hold a pipe, and a pipe +// ends a cell in every table syntax Markdown has. +func (m *Manifest) Instructions(manifestName string) []byte { + if !m.Explains() { + return nil + } + var b strings.Builder + m.writeOpening(&b, manifestName) + writeOutcomes(&b) + groups := groupsOf(m.Files) + for _, g := range groups { + fmt.Fprintf(&b, "\n## %s (%s)\n\n", groupHeading(g.name, len(groups)), core.Count(len(g.files), "file", "files")) + for _, run := range runsOf(g.files) { + writeRun(&b, run) + } + } + return []byte(b.String()) +} + +func (m *Manifest) writeOpening(b *strings.Builder, manifestName string) { + b.WriteString("# What these files are for\n\n") + var total int64 + for _, f := range m.Files { + total += f.Bytes + } + fmt.Fprintf(b, "This directory holds %s, %s in all, written by %s %s", + core.Count(len(m.Files), "file", "files"), core.ExactBytes(total), m.Tool.Name, m.Tool.Version) + if p := m.Run.Preset; p != nil { + fmt.Fprintf(b, " from the preset %s", codeSpan(p.ID)) + if p.Question != "" { + fmt.Fprintf(b, ". It answers one question:\n\n> %s\n\n", oneLine(p.Question)) + } else { + b.WriteString(".\n\n") + } + } else { + b.WriteString(".\n\n") + } + fmt.Fprintf(b, "Each file below says what it is, why it is in the set and what your system is expected to do with it. "+ + "The same facts are in %s, in a form a program can read.\n", codeSpan(manifestName)) +} + +func writeOutcomes(b *strings.Builder) { + b.WriteString("\n## How to read what is expected\n\n" + + "- **accept** - your system should take the file.\n" + + "- **reject** - your system should turn the file away.\n" + + "- **sanitize** - your system should take the file and clean it, for example by renaming it.\n" + + "- **unspecified** - it depends on the rules of your system. The file is here so that you decide, and then check that what happens is what you meant.\n\n" + + "The word in brackets after it names the rule the file is about, such as `size_limit` or `filename_invalid`.\n") +} + +// runsOf cuts a group's files into runs of one target each, so that fifty +// files of a mass upload read as one entry rather than fifty copies of one +// sentence. A file that failed, or was not written, stands on its own, because +// what happened to it is said about it alone. +func runsOf(files []File) [][]File { + var out [][]File + for _, f := range files { + if n := len(out); n > 0 && sameRun(out[n-1][0], f) { + out[n-1] = append(out[n-1], f) + continue + } + out = append(out, []File{f}) + } + return out +} + +func sameRun(a, b File) bool { + whole := func(f File) bool { return !f.Failed && f.Materialized } + return a.TargetID != "" && a.TargetID == b.TargetID && whole(a) && whole(b) +} + +// writeRun is one entry of the list: a file, or a run of files of one target. +func writeRun(b *strings.Builder, run []File) { + if len(run) == 1 { + writeFile(b, run[0]) + return + } + var total int64 + for _, f := range run { + total += f.Bytes + } + first, last := run[0], run[len(run)-1] + fmt.Fprintf(b, "- %s, %s to %s - %s, %s in all.", core.Count(len(run), "file", "files"), + codeSpan(core.Shown(first.Name)), codeSpan(core.Shown(last.Name)), first.Format, core.ExactBytes(total)) + writeExpected(b, first.Expected) + writePurpose(b, first) +} + +func writeFile(b *strings.Builder, f File) { + fmt.Fprintf(b, "- %s - %s, %s.", codeSpan(core.Shown(f.Name)), f.Format, core.ExactBytes(f.Bytes)) + writeExpected(b, f.Expected) + switch { + case f.Failed: + fmt.Fprintf(b, " Not written: %s\n", oneLine(f.Error)) + case !f.Materialized: + b.WriteString(" Described in the manifest and not written to the disk.\n") + } + writePurpose(b, f) +} + +// writeExpected ends the first line of an entry with what is expected of it. +func writeExpected(b *strings.Builder, e Expected) { + if e.Outcome != "" { + fmt.Fprintf(b, " Expected: **%s**", e.Outcome) + if e.Reason != "" { + fmt.Fprintf(b, " (%s)", codeSpan(e.Reason)) + } + b.WriteString(".") + } + b.WriteString("\n") +} + +// writePurpose is the second line of an entry: what the file is for. +func writePurpose(b *strings.Builder, f File) { + if f.Purpose != "" { + fmt.Fprintf(b, " %s\n", oneLine(f.Purpose)) + return + } + b.WriteString(" No purpose was given for this file.\n") +} + +// fileGroup is the files of one group, in the order the manifest lists them. +type fileGroup struct { + name string + files []File +} + +// groupsOf puts the files under their groups, the groups in the order their +// first file comes, so the instructions read in the order the run wrote. +func groupsOf(files []File) []fileGroup { + var out []fileGroup + at := map[string]int{} + for _, f := range files { + i, seen := at[f.Group] + if !seen { + i = len(out) + at[f.Group] = i + out = append(out, fileGroup{name: f.Group}) + } + out[i].files = append(out[i].files, f) + } + return out +} + +// groupHeading is what a group's section is called. Files nobody grouped are +// "the files" when they are all there is, and set apart from the rest when not. +func groupHeading(name string, groups int) string { + switch { + case name != "": + return oneLine(name) + case groups == 1: + return "The files" + default: + return "Files in no group" + } +} + +// oneLine is text from a recipe made safe to stand on one line of a list: any +// run of white space, a line break included, becomes one space, and a +// character nobody can see is written as an escape. +func oneLine(s string) string { + return core.Shown(strings.Join(strings.Fields(s), " ")) +} + +// codeSpan puts a name between backticks, so that nothing in it is read as +// Markdown - a name here can be