diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a5823..653cf27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -306,6 +306,25 @@ because it turns other people's test suites red. ### Changed +- **Two commands now refuse a name they cannot answer about, instead of + quietly answering about a different one.** + + ``` + tfg formats png svg described png and said nothing about svg, ending with 0 + tfg preset list some-name printed the whole list and ignored the name, ending with 0 + ``` + + Both now end with 2 and name the word they could not use. A script that + asked about the wrong thing was getting a confident answer about something + else, which is worse than being told no. + + `tfg preset list` takes no name at all, so anything after it was always a + mistake - most likely somebody reaching for `tfg preset show`. + The refusal says so. + + Nothing changes when you pass the right number of names, and no generated + file moves a byte. + - **A spreadsheet can now be built wider than a spreadsheet can open.** The `columns` setting of `xlsx` used to stop at 64. It now reaches 32768, which is the ceiling `csv` already had. diff --git a/internal/cli/formats.go b/internal/cli/formats.go index 466cb9c..ca0be41 100644 --- a/internal/cli/formats.go +++ b/internal/cli/formats.go @@ -172,9 +172,13 @@ Flags: if err := fs.Parse(rest); err != nil { return ExitUsage } - wanted := leading - if wanted == "" && fs.NArg() == 1 { - wanted = fs.Arg(0) + // Two names used to describe the first and say nothing about the second, + // ending with zero: measured 2026-09-09, "tfg formats png svg" printed the + // card for png and ignored svg, so a script asking about the wrong thing + // got a confident answer about something else. O196. + wanted, ok := atMostOneName(leading, fs, errOut) + if !ok { + return ExitUsage } if wanted != "" { diff --git a/internal/cli/preset.go b/internal/cli/preset.go index 09de047..d2a9b36 100644 --- a/internal/cli/preset.go +++ b/internal/cli/preset.go @@ -9,7 +9,6 @@ import ( "sort" "strings" - "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/format" "github.com/donislawdev/TestingFilesGenerator/internal/manifest" @@ -338,251 +337,3 @@ func targetsFromPreset(fs *flag.FlagSet, g *generateOpts, given map[string]bool, opt.Preset = record(expanded) return targetsFromParsedRecipe(rec, hash, g, given, opt), ExitOK } - -func presetCmd(ctx context.Context, args []string, out, errOut io.Writer) int { - if len(args) > 0 { - switch args[0] { - case "list": - return presetList(args[1:], out, errOut) - case "show": - return presetShow(ctx, args[1:], out, errOut) - case "eject": - return presetEject(args[1:], out, errOut) - } - } - - // Asking about the command itself, before any operation is named. - if helpRequested(args) { - presetUsage(out) - return ExitOK - } - if len(args) == 0 { - fmt.Fprintln(errOut, "tfg: preset takes one operation: list, show or eject. Example: tfg preset list") - } else { - fmt.Fprintf(errOut, "tfg: preset has no operation called %q. It takes list, show or eject.\n", args[0]) - } - presetUsage(errOut) - return ExitUsage -} - -func presetUsage(w io.Writer) { - fmt.Fprint(w, `tfg preset - build a set of files from a named test question. - -Usage: - tfg preset list what this build offers - tfg preset show what it takes and what it would produce - tfg preset eject > my.yaml the recipe it stands for, to edit - -A preset is a recipe with a name. Ejecting one gives back an ordinary recipe -file, so nothing here is a closed box. - -Run "tfg generate --preset " to produce the files. -`) -} - -// presetFlagSet builds the flag set of an operation taking one preset id. -// -// The id has to be read before parsing, because the parameters of the preset -// are flags and there is no way to register them until it is known which they -// are. -// asJSON is filled in for the operations that have a machine readable form and -// nil for the one that does not - a recipe is already machine readable, and a -// second encoding of it would be a second thing to keep in step. -func presetFlagSet(name string, args []string, out, errOut io.Writer, usage func(io.Writer), asJSON *bool) ( - *preset.Expansion, int) { - - fs := flag.NewFlagSet("preset "+name, flag.ContinueOnError) - fs.SetOutput(errOut) - fs.Usage = func() { usage(errOut) } - if helpRequested(args) { - usage(out) - return nil, ExitOK - } - // Registered before the parameters, so a preset declaring one called json - // is caught by the collision check rather than by the flag package panicking. - if asJSON != nil { - fs.BoolVar(asJSON, "json", false, "write the answer as JSON to standard output") - } - - id, rest := splitLeadingPath(args) - if id == "" { - fmt.Fprintf(errOut, "tfg: preset %s takes the id of one preset. Run \"tfg preset list\" to see them.\n", name) - return nil, ExitUsage - } - p, err := preset.Get(id) - if err != nil { - fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) - return nil, classify(err) - } - if clash := clashingParameter(fs, p); clash != "" { - fmt.Fprintf(errOut, "tfg: the preset %s declares a parameter called %q and that is already a flag of this command. This is a fault in the build rather than in what you typed, and there is nothing you can do about it from here.\n", p.ID, clash) - return nil, ExitRuntime - } - registerPresetFlags(fs, p) - - if err := fs.Parse(rest); err != nil { - return nil, ExitUsage - } - if fs.NArg() > 0 { - fmt.Fprintf(errOut, "tfg: preset %s takes one preset id and %q came after it. Give the parameters as flags, for example --limit 10mb.\n", name, fs.Arg(0)) - return nil, ExitUsage - } - - expanded, err := preset.Expand(id, givenPresetArgs(fs, p)) - if err != nil { - fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) - return nil, classify(err) - } - return expanded, ExitOK -} - -func presetList(args []string, out, errOut io.Writer) int { - fs := flag.NewFlagSet("preset list", flag.ContinueOnError) - fs.SetOutput(errOut) - asJSON := fs.Bool("json", false, "write the list as JSON to standard output") - usage := func(w io.Writer) { - fmt.Fprint(w, `tfg preset list - the test questions this build can answer. - -Usage: - tfg preset list - tfg preset list --json - -Flags: -`) - fs.SetOutput(w) - fs.PrintDefaults() - fs.SetOutput(errOut) - } - fs.Usage = func() { usage(errOut) } - if helpRequested(args) { - usage(out) - return ExitOK - } - if err := fs.Parse(args); err != nil { - return ExitUsage - } - - all := preset.All() - if *asJSON { - list := make([]presetEntry, 0, len(all)) - for _, p := range all { - list = append(list, presetEntryFor(p)) - } - return renderJSON(list, out, errOut) - } - - // An empty build says so rather than printing a heading over nothing. - if len(all) == 0 { - fmt.Fprint(out, "This build registers no presets.\n") - return ExitOK - } - fmt.Fprintf(out, "%-18s %s\n", "PRESET", "QUESTION IT ANSWERS") - for _, p := range all { - fmt.Fprintf(out, "%-18s %s\n", p.ID, p.Question) - } - fmt.Fprint(out, "\nRun \"tfg preset show \" for what one takes and what it would produce.\n") - return ExitOK -} - -func presetShow(ctx context.Context, args []string, out, errOut io.Writer) int { - usage := func(w io.Writer) { - fmt.Fprint(w, `tfg preset show - what a preset takes and what it would produce. - -The budget is counted from the plan, at the parameters you gave, so it is the -number of files and bytes this run would really write. - -Usage: - tfg preset show size-boundaries - tfg preset show size-boundaries --limit 20mb --format png - tfg preset show size-boundaries --json -`) - } - var asJSON bool - expanded, code := presetFlagSet("show", args, out, errOut, usage, &asJSON) - if expanded == nil { - return code - } - - b, err := budgetOf(ctx, expanded) - if err != nil { - fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) - return classify(err) - } - - if asJSON { - entry := presetEntryFor(expanded.Preset) - entry.Budget = &b - entry.Defaulted = expanded.Defaulted - entry.Notes = expanded.Notes() - return renderJSON(entry, out, errOut) - } - describePreset(expanded, b, out) - return ExitOK -} - -func describePreset(e *preset.Expansion, b budget, out io.Writer) { - p := e.Preset - fmt.Fprintf(out, "%s - %s\n%s\n", p.ID, p.Title, p.Question) - - if len(p.Parameters) > 0 { - fmt.Fprint(out, "\nparameters:\n") - for _, param := range p.Parameters { - fmt.Fprintf(out, " --%-12s %s\n", param.Name, param.Allowed()) - if param.Detail != "" { - fmt.Fprintf(out, " %-14s %s\n", "", param.Detail) - } - } - } - for _, name := range p.Reads { - fmt.Fprintf(out, " --%-12s the global flag, this preset gives it a default\n", name) - } - - fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %s total, format %s\n", - core.Count(b.Targets, "target", "targets"), core.Count(b.Files, "file", "files"), - core.ExactBytes(b.Bytes), strings.Join(b.Formats, ", ")) - for _, note := range e.Notes() { - fmt.Fprintf(out, "\nnote: %s\n", note) - } - - if len(p.Catches) > 0 { - fmt.Fprint(out, "\nwhat it typically catches:\n") - for _, c := range p.Catches { - fmt.Fprintf(out, " - %s\n", c) - } - } - fmt.Fprintf(out, "\nRun \"tfg preset eject %s\" for the recipe, or \"tfg generate --preset %s\" to produce the files.\n", - p.ID, p.ID) -} - -func presetEject(args []string, out, errOut io.Writer) int { - usage := func(w io.Writer) { - fmt.Fprint(w, `tfg preset eject - the recipe a preset stands for. - -Prints an ordinary recipe file. Edit it, commit it, run it with tfg generate - -from here on it is yours and nothing about it is special. - -The recipe goes to standard output and everything else to standard error, so -"tfg preset eject size-boundaries > my.yaml" gives a clean file. - -Usage: - tfg preset eject size-boundaries > my.yaml - tfg preset eject size-boundaries --limit 20mb --format png > my.yaml -`) - } - expanded, code := presetFlagSet("eject", args, out, errOut, usage, nil) - if expanded == nil { - return code - } - - // The note goes to the error channel. The recipe is the data here, and a - // sentence about a number we chose has no business inside a file somebody - // is about to commit. - for _, note := range expanded.Notes() { - fmt.Fprintf(errOut, "note: %s\n", note) - } - if _, err := out.Write(expanded.Source); err != nil { - fmt.Fprintf(errOut, "tfg: cannot write the recipe: %s\n", describeError(err)) - return ExitIO - } - return ExitOK -} diff --git a/internal/cli/presetcmd.go b/internal/cli/presetcmd.go new file mode 100644 index 0000000..7bea1bd --- /dev/null +++ b/internal/cli/presetcmd.go @@ -0,0 +1,279 @@ +// Part of package cli. See cli.go. +// +// The tfg preset command lives here and the machinery behind +// generate --preset lives in preset.go beside it. One file held both and +// grew past the file ceiling on 2026-09-09, and the split follows what the +// parts DO rather than where the line count happened to fall: everything +// here answers somebody who typed tfg preset, and everything there turns a +// named question into targets for a run. +package cli + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/preset" +) + +func presetCmd(ctx context.Context, args []string, out, errOut io.Writer) int { + if len(args) > 0 { + switch args[0] { + case "list": + return presetList(args[1:], out, errOut) + case "show": + return presetShow(ctx, args[1:], out, errOut) + case "eject": + return presetEject(args[1:], out, errOut) + } + } + + // Asking about the command itself, before any operation is named. + if helpRequested(args) { + presetUsage(out) + return ExitOK + } + if len(args) == 0 { + fmt.Fprintln(errOut, "tfg: preset takes one operation: list, show or eject. Example: tfg preset list") + } else { + fmt.Fprintf(errOut, "tfg: preset has no operation called %q. It takes list, show or eject.\n", args[0]) + } + presetUsage(errOut) + return ExitUsage +} + +func presetUsage(w io.Writer) { + fmt.Fprint(w, `tfg preset - build a set of files from a named test question. + +Usage: + tfg preset list what this build offers + tfg preset show what it takes and what it would produce + tfg preset eject > my.yaml the recipe it stands for, to edit + +A preset is a recipe with a name. Ejecting one gives back an ordinary recipe +file, so nothing here is a closed box. + +Run "tfg generate --preset " to produce the files. +`) +} + +// presetFlagSet builds the flag set of an operation taking one preset id. +// +// The id has to be read before parsing, because the parameters of the preset +// are flags and there is no way to register them until it is known which they +// are. +// asJSON is filled in for the operations that have a machine readable form and +// nil for the one that does not - a recipe is already machine readable, and a +// second encoding of it would be a second thing to keep in step. +func presetFlagSet(name string, args []string, out, errOut io.Writer, usage func(io.Writer), asJSON *bool) ( + *preset.Expansion, int) { + + fs := flag.NewFlagSet("preset "+name, flag.ContinueOnError) + fs.SetOutput(errOut) + fs.Usage = func() { usage(errOut) } + if helpRequested(args) { + usage(out) + return nil, ExitOK + } + // Registered before the parameters, so a preset declaring one called json + // is caught by the collision check rather than by the flag package panicking. + if asJSON != nil { + fs.BoolVar(asJSON, "json", false, "write the answer as JSON to standard output") + } + + id, rest := splitLeadingPath(args) + if id == "" { + fmt.Fprintf(errOut, "tfg: preset %s takes the id of one preset. Run \"tfg preset list\" to see them.\n", name) + return nil, ExitUsage + } + p, err := preset.Get(id) + if err != nil { + fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) + return nil, classify(err) + } + if clash := clashingParameter(fs, p); clash != "" { + fmt.Fprintf(errOut, "tfg: the preset %s declares a parameter called %q and that is already a flag of this command. This is a fault in the build rather than in what you typed, and there is nothing you can do about it from here.\n", p.ID, clash) + return nil, ExitRuntime + } + registerPresetFlags(fs, p) + + if err := fs.Parse(rest); err != nil { + return nil, ExitUsage + } + if fs.NArg() > 0 { + fmt.Fprintf(errOut, "tfg: preset %s takes one preset id and %q came after it. Give the parameters as flags, for example --limit 10mb.\n", name, fs.Arg(0)) + return nil, ExitUsage + } + + expanded, err := preset.Expand(id, givenPresetArgs(fs, p)) + if err != nil { + fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) + return nil, classify(err) + } + return expanded, ExitOK +} + +func presetList(args []string, out, errOut io.Writer) int { + fs := flag.NewFlagSet("preset list", flag.ContinueOnError) + fs.SetOutput(errOut) + asJSON := fs.Bool("json", false, "write the list as JSON to standard output") + usage := func(w io.Writer) { + fmt.Fprint(w, `tfg preset list - the test questions this build can answer. + +Usage: + tfg preset list + tfg preset list --json + +Flags: +`) + fs.SetOutput(w) + fs.PrintDefaults() + fs.SetOutput(errOut) + } + fs.Usage = func() { usage(errOut) } + if helpRequested(args) { + usage(out) + return ExitOK + } + if err := fs.Parse(args); err != nil { + return ExitUsage + } + // This operation takes no name, and until 2026-09-09 anything written + // after it was dropped without a word: tfg preset list size-boundaries + // printed the whole list and ended with zero, so somebody reaching for + // show got list and no sign of it. Found while measuring the same + // silence in tfg formats, O196. + if extra := fs.Args(); len(extra) > 0 { + fmt.Fprintf(errOut, "tfg: preset list takes no name and %q came after it. "+ + "Run \"tfg preset list\" for all of them, or \"tfg preset show %s\" for one.\n", + extra[0], extra[0]) + return ExitUsage + } + + all := preset.All() + if *asJSON { + list := make([]presetEntry, 0, len(all)) + for _, p := range all { + list = append(list, presetEntryFor(p)) + } + return renderJSON(list, out, errOut) + } + + // An empty build says so rather than printing a heading over nothing. + if len(all) == 0 { + fmt.Fprint(out, "This build registers no presets.\n") + return ExitOK + } + fmt.Fprintf(out, "%-18s %s\n", "PRESET", "QUESTION IT ANSWERS") + for _, p := range all { + fmt.Fprintf(out, "%-18s %s\n", p.ID, p.Question) + } + fmt.Fprint(out, "\nRun \"tfg preset show \" for what one takes and what it would produce.\n") + return ExitOK +} + +func presetShow(ctx context.Context, args []string, out, errOut io.Writer) int { + usage := func(w io.Writer) { + fmt.Fprint(w, `tfg preset show - what a preset takes and what it would produce. + +The budget is counted from the plan, at the parameters you gave, so it is the +number of files and bytes this run would really write. + +Usage: + tfg preset show size-boundaries + tfg preset show size-boundaries --limit 20mb --format png + tfg preset show size-boundaries --json +`) + } + var asJSON bool + expanded, code := presetFlagSet("show", args, out, errOut, usage, &asJSON) + if expanded == nil { + return code + } + + b, err := budgetOf(ctx, expanded) + if err != nil { + fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) + return classify(err) + } + + if asJSON { + entry := presetEntryFor(expanded.Preset) + entry.Budget = &b + entry.Defaulted = expanded.Defaulted + entry.Notes = expanded.Notes() + return renderJSON(entry, out, errOut) + } + describePreset(expanded, b, out) + return ExitOK +} + +func describePreset(e *preset.Expansion, b budget, out io.Writer) { + p := e.Preset + fmt.Fprintf(out, "%s - %s\n%s\n", p.ID, p.Title, p.Question) + + if len(p.Parameters) > 0 { + fmt.Fprint(out, "\nparameters:\n") + for _, param := range p.Parameters { + fmt.Fprintf(out, " --%-12s %s\n", param.Name, param.Allowed()) + if param.Detail != "" { + fmt.Fprintf(out, " %-14s %s\n", "", param.Detail) + } + } + } + for _, name := range p.Reads { + fmt.Fprintf(out, " --%-12s the global flag, this preset gives it a default\n", name) + } + + fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %s total, format %s\n", + core.Count(b.Targets, "target", "targets"), core.Count(b.Files, "file", "files"), + core.ExactBytes(b.Bytes), strings.Join(b.Formats, ", ")) + for _, note := range e.Notes() { + fmt.Fprintf(out, "\nnote: %s\n", note) + } + + if len(p.Catches) > 0 { + fmt.Fprint(out, "\nwhat it typically catches:\n") + for _, c := range p.Catches { + fmt.Fprintf(out, " - %s\n", c) + } + } + fmt.Fprintf(out, "\nRun \"tfg preset eject %s\" for the recipe, or \"tfg generate --preset %s\" to produce the files.\n", + p.ID, p.ID) +} + +func presetEject(args []string, out, errOut io.Writer) int { + usage := func(w io.Writer) { + fmt.Fprint(w, `tfg preset eject - the recipe a preset stands for. + +Prints an ordinary recipe file. Edit it, commit it, run it with tfg generate - +from here on it is yours and nothing about it is special. + +The recipe goes to standard output and everything else to standard error, so +"tfg preset eject size-boundaries > my.yaml" gives a clean file. + +Usage: + tfg preset eject size-boundaries > my.yaml + tfg preset eject size-boundaries --limit 20mb --format png > my.yaml +`) + } + expanded, code := presetFlagSet("eject", args, out, errOut, usage, nil) + if expanded == nil { + return code + } + + // The note goes to the error channel. The recipe is the data here, and a + // sentence about a number we chose has no business inside a file somebody + // is about to commit. + for _, note := range expanded.Notes() { + fmt.Fprintf(errOut, "note: %s\n", note) + } + if _, err := out.Write(expanded.Source); err != nil { + fmt.Fprintf(errOut, "tfg: cannot write the recipe: %s\n", describeError(err)) + return ExitIO + } + return ExitOK +} diff --git a/internal/guard/codeshape_test.go b/internal/guard/codeshape_test.go index 782b5a6..dcae9a5 100644 --- a/internal/guard/codeshape_test.go +++ b/internal/guard/codeshape_test.go @@ -47,7 +47,10 @@ const ( // Lowered from 457 on 2026-09-06: writing a file moved out of engine.go // into parallel.go, beside the goroutines that do it. The longest file in // the tree is somewhere else now. - longestFile = 433 + // Lowered from 433 on 2026-09-09: the tfg preset command moved out of + // preset.go into presetcmd.go, leaving the machinery behind generate + // --preset on its own. The longest file is engine.go again. + longestFile = 408 // 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/crowding_test.go b/internal/guard/crowding_test.go index 13d4f9d..96cd726 100644 --- a/internal/guard/crowding_test.go +++ b/internal/guard/crowding_test.go @@ -60,8 +60,10 @@ const ( // when the per file work moved into compare. The ratchet only tightens. // Lowered from 2 on 2026-09-06: engine.go dropped out of the crowded band // when writing a file moved into parallel.go. + // Lowered from 1 on 2026-09-09: preset.go split in two, so no file sits + // in the band under the file ceiling any more. crowdedFunctions = 9 - crowdedFiles = 1 + crowdedFiles = 0 ) func TestNothingIsQuietlyCreepingTowardsTheCeiling(t *testing.T) { diff --git a/internal/guard/flagnames_test.go b/internal/guard/flagnames_test.go index e38fa19..f0e9503 100644 --- a/internal/guard/flagnames_test.go +++ b/internal/guard/flagnames_test.go @@ -6,6 +6,7 @@ import ( "go/token" "path/filepath" "regexp" + "sort" "strconv" "strings" "testing" @@ -29,27 +30,70 @@ import ( // flag names. A list would need a line adding every time a flag is, which is // the kind of guard somebody forgets to extend - and the defect is the dashes, // not which word follows them. +// packagesBelowTheSurfaces is every package the layer map puts under the +// command line and the window, as a path from this directory. +// +// Derived rather than written out, and that is the whole of O197. The list +// here used to name seven directories by hand, and internal/damage arrived on +// 2026-09-08 without joining it - a package on layer 2, under BOTH surfaces, +// whose refusals the window and the command line both show. Measured when the +// gap was found: zero flag spellings in it, so the hole was empty. It was still +// a hole, and the next package below the surfaces would have fallen in it too. +// +// The layer map is the right source rather than a walk of internal, because +// TestLayeringHoldsForEveryPackage already refuses a package that is neither on +// the ladder nor declared test only. So this covers a package added tomorrow +// with nothing to remember, and it leaves out the two test only packages on +// purpose: internal/oracle carries the flags of other people's programs in raw +// strings - inkscape and ffprobe are called with real command lines - and +// measured on 2026-09-09 it holds three of them. A walk of internal would +// redden on those, which is how a guard gets switched off inside a week. +func packagesBelowTheSurfaces(t *testing.T) []string { + t.Helper() + + // The surfaces are layer 4. Anything above them is a binary, anything + // below is what both of them show. + const surfaces = 4 + + var out []string + for pkg, n := range layer { + if n >= surfaces { + continue + } + rest, inside := strings.CutPrefix(pkg, "internal/") + if !inside { + // Nothing below the surfaces lives outside internal today. + // Saying so rather than silently skipping it, because a + // package that did would go unscanned and look scanned. + t.Errorf("%s sits below the surfaces and outside internal, so this guard does not know where to read it", pkg) + continue + } + out = append(out, filepath.Join("..", rest)) + } + if len(out) == 0 { + t.Fatal("the layer map put no package below the surfaces, so this guard would read nothing") + } + sort.Strings(out) + return out +} + func TestNoMessageBelowTheSurfacesIsWrittenInFlagSpelling(t *testing.T) { // A dash pair followed by a letter. "|---|---|" in generated markdown is // three dashes and does not match, and neither does a range or an em dash // written as two. flagLike := regexp.MustCompile(`--[a-z]`) - dirs := []string{ - "../engine", "../core", "../preset", "../recipe", "../manifest", - "../audit", "../format", - } + dirs := packagesBelowTheSurfaces(t) scanned, found := 0, 0 for _, dir := range dirs { matches, err := filepath.Glob(filepath.Join(dir, "*.go")) if err != nil { t.Fatal(err) } - nested, err := filepath.Glob(filepath.Join(dir, "*", "*.go")) - if err != nil { - t.Fatal(err) + if len(matches) == 0 { + t.Errorf("%s is on the ladder and holds no Go file, so the layer map names a package that is not there", dir) } - for _, path := range append(matches, nested...) { + for _, path := range matches { if strings.HasSuffix(path, "_test.go") { continue } diff --git a/internal/guard/listingarguments_test.go b/internal/guard/listingarguments_test.go new file mode 100644 index 0000000..3e8e477 --- /dev/null +++ b/internal/guard/listingarguments_test.go @@ -0,0 +1,81 @@ +package guard + +import ( + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" +) + +// A listing command refuses a name it cannot answer about, rather than +// answering about a different one. +// +// Measured on 2026-09-09, on the built binary: "tfg formats png svg" printed +// the card for png, said nothing at all about svg and ended with zero, and +// "tfg preset list size-boundaries" printed the whole list and ended with zero +// though that operation takes no name. Both are silence about an argument +// somebody typed - untouchable rule 6 read from the other end - and both are +// worse in a script than a refusal, because a confident answer about the wrong +// thing is indistinguishable from a right one. O196. +// +// The refusal has to NAME the word it could not use. An exit code alone is a +// weak thing to assert on: a command that refused everything with the same code +// would satisfy it, and so would one that refused for an unrelated reason. +// +// Two commands answered this correctly already and are here as the other half +// of the comparison, because the defect is not that a check is missing +// somewhere - it is that four commands answered one question two ways. +func TestAListingCommandRefusesANameItCannotAnswerAbout(t *testing.T) { + refusals := []struct { + what string + args []string + extra string + }{ + {"formats asked about two", []string{"formats", "png", "svg"}, "svg"}, + {"damage asked about two", []string{"damage", "zero-head", "other"}, "other"}, + {"preset list given a name", []string{"preset", "list", "size-boundaries"}, "size-boundaries"}, + {"preset show given two", []string{"preset", "show", "size-boundaries", "extra"}, "extra"}, + {"verify given two", []string{"verify", "a.json", "b.json"}, ""}, + } + + for _, c := range refusals { + t.Run(c.what, func(t *testing.T) { + code, stdout, errOut := run(t, c.args...) + if code != cli.ExitUsage { + t.Errorf("ended with %d, expected %d - a name it cannot use is a mistake "+ + "in what was typed\nstdout: %s\nstderr: %s", code, cli.ExitUsage, stdout, errOut) + } + if stdout != "" { + t.Errorf("a refused run wrote to stdout, so a pipe receives half an answer: %q", stdout) + } + // Named rather than merely refused. Without this the guard passes + // for a command that says "usage" and leaves the reader to work out + // which of the words they typed was the problem. + if c.extra != "" && !strings.Contains(errOut, c.extra) { + t.Errorf("the refusal does not name %q, the word it could not use: %s", c.extra, errOut) + } + }) + } + + // The control, and it is not decoration: every assertion above is satisfied + // by a build that refuses these commands outright. This half says the + // refusal is about the extra name and nothing else. + answers := [][]string{ + {"formats"}, + {"formats", "png"}, + {"damage"}, + {"damage", "zero-head"}, + {"preset", "list"}, + } + for _, args := range answers { + t.Run("still answers "+strings.Join(args, " "), func(t *testing.T) { + code, stdout, errOut := run(t, args...) + if code != cli.ExitOK { + t.Errorf("ended with %d, expected %d: %s", code, cli.ExitOK, errOut) + } + if stdout == "" { + t.Error("answered with nothing on stdout") + } + }) + } +}