diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c4d1b..46a5823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,28 @@ because it turns other people's test suites red. ### Added +- **A new command says what this build can break: `tfg damage`.** + + ``` + tfg damage every damage, and what each one does to the bytes + tfg damage zero-head what one of them takes + tfg damage --json the same, for a script + ``` + + The window already drew the settings of a chosen damage. The command line named + them and stopped there, so the only way to learn that `zero-head` takes `bytes`, + and that `bytes` runs from 4 to 4096, was to type a wrong value and read the + refusal. + + Each one is listed with the smallest file it can be given. That number follows + the settings - for `zero-head` it is the number of bytes you asked it to zero - so + the column is measured with the defaults and says so. + + Asking about a damage that does not exist ends with 2, the code that means the + invocation was mistyped, and it is the same code `--damage` already gave for the + same mistake. Asking about two at once is refused rather than answered about the + first. + - **Files can be broken on purpose.** A target takes `damage`, and the files it produces are ones a reader refuses: diff --git a/README.md b/README.md index 6a8d7ab..b4cd509 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ tfg cleanup remove the files a manifest lists tfg recipe fmt print a recipe in its settled shape tfg preset build a set of files from a named test question tfg formats list the formats this build supports +tfg damage list the ways this build can break a file on purpose tfg version print the tool version tfg license print the licence and what it means for generated files ``` @@ -326,6 +327,29 @@ tfg formats [--json] every format, with fidelity, determinism and smallest s tfg formats what a single format accepts ``` +### `tfg damage` + +``` +tfg damage [--json] every damage, with the smallest file it takes and its settings +tfg damage what one damage does to the bytes, and what it takes +``` + +A damaged file comes out **exactly** the size you asked for and is one a reader +refuses, so the manifest records it as expected to be rejected. That is the third +question an upload validator asks - does it open - and until damage arrived every +file this tool wrote was well formed by definition. + +Use them with `tfg generate --damage `, repeatable and applied in the order +given, or with the `damage` key of a target in a recipe: + +``` +tfg generate --format png --size 20kb --count 10 --damage zero-head:bytes=16 +``` + +The smallest file a damage can be given follows its settings, so the column is +measured with the defaults. Ask for less and the run is refused before anything is +written, naming a size that would work. + ## 📜 Recipes A recipe is a YAML file describing a whole run. Commit it beside your tests and diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 16cdceb..fe0e927 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -86,6 +86,8 @@ func Run(ctx context.Context, args []string, out, errOut io.Writer) int { return presetCmd(ctx, args[1:], out, errOut) case "formats": return formats(args[1:], out, errOut) + case "damage": + return damageCmd(args[1:], out, errOut) case "--version", "version": fmt.Fprintln(out, version.Version) return ExitOK @@ -151,6 +153,7 @@ Commands: recipe fmt print a recipe in its settled shape preset build a set of files from a named test question formats list the formats this build supports + damage list the ways this build can break a file on purpose version print the tool version license print the licence and what it means for generated files diff --git a/internal/cli/damagecmd.go b/internal/cli/damagecmd.go new file mode 100644 index 0000000..bf62835 --- /dev/null +++ b/internal/cli/damagecmd.go @@ -0,0 +1,233 @@ +// Part of package cli. See cli.go. +package cli + +import ( + "flag" + "fmt" + "io" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/damage" +) + +// damageEntry is what "tfg damage --json" returns. +// +// The parameters ride in the same propertyEntry the format and the preset lists +// use, under the same key the preset list gives them. That is not tidiness: a +// damage parameter IS a format.Property, so a script that already knows how to +// draw a field from "tfg preset show --json" draws this one with no new code. +type damageEntry struct { + ID string `json:"id"` + Detail string `json:"detail"` + // SmallestFileBytes is the smallest file this damage can be given, with + // its settings left at their defaults. + // + // At the defaults on purpose, because that is the number somebody reading + // this column is about to ask a run for. The floor is a function of the + // settings - zero-head's floor IS its bytes value - so a person who raises + // the setting raises this, and the refusal names the new number rather + // than this one. tfg formats made the other choice once and printed a + // floor that runs refused: measured 2026-08-03, pdf announced 3265 and + // took 3286. + SmallestFileBytes int64 `json:"smallest_file_bytes"` + Parameters []propertyEntry `json:"parameters,omitempty"` +} + +// damageEntryFor is one damage as a script sees it. +func damageEntryFor(d damage.Descriptor) damageEntry { + params := make([]propertyEntry, 0, len(d.Parameters)) + for _, p := range d.Parameters { + params = append(params, propertyEntry{ + Name: p.Name, Kind: string(p.Kind), Min: p.Min, Max: p.Max, + Unit: p.Unit, Choices: p.Choices, Default: p.Default, Detail: p.Detail, + }) + } + return damageEntry{ + ID: d.ID, Detail: d.Detail, + SmallestFileBytes: defaultFloor(d), Parameters: params, + } +} + +// defaultFloor is the smallest file this damage takes when nobody states a +// setting. +// +// Asked of the declaration rather than worked out here, so a damage whose floor +// is arithmetic on two settings answers for itself. +func defaultFloor(d damage.Descriptor) int64 { + return d.Floor(d.Defaults()) +} + +// damageExample is the flag a person would type to use this damage, with its +// first setting at the declared default. +// +// Built from the declaration rather than written out, because the colon and +// comma syntax is the one thing about this flag nobody guesses, and an example +// naming a setting that no longer exists teaches the wrong thing twice. +func damageExample(d damage.Descriptor) string { + out := "--damage " + d.ID + if len(d.Parameters) > 0 { + p := d.Parameters[0] + value := p.Default + if value == "" { + value = "" + } + out += ":" + p.Name + "=" + value + } + return out +} + +// describeOneDamage prints everything one damage declares. +// +// The sentence comes first and the settings after it, which is the other way +// round from a format: a format id says what the file will be and a damage id +// does not, so the sentence is the answer rather than the footnote. +func describeOneDamage(d damage.Descriptor, out io.Writer) { + fmt.Fprintf(out, "%s - smallest file %s with the settings below\n", + d.ID, core.ExactBytes(defaultFloor(d))) + fmt.Fprintf(out, " %s\n", d.Detail) + + if len(d.Parameters) == 0 { + fmt.Fprint(out, "\nThis damage takes no settings.\n") + return + } + fmt.Fprintf(out, "\nsettings, written after a colon: %s\n", damageExample(d)) + for _, p := range d.Parameters { + fmt.Fprintf(out, " %-14s %s\n", p.Name, p.Allowed()) + if p.Detail != "" { + fmt.Fprintf(out, " %-14s %s\n", "", p.Detail) + } + } +} + +// settingsColumn is the names of what a damage takes, for the list. +// +// A word rather than an empty cell when there are none, because a blank column +// reads as "not filled in yet" and this is an answer. +func settingsColumn(d damage.Descriptor) string { + names := d.ParameterNames() + if len(names) == 0 { + return "none" + } + return strings.Join(names, ", ") +} + +func listDamages(out io.Writer) { + fmt.Fprintf(out, "%-14s %-14s %s\n", "DAMAGE", "SMALLEST FILE", "SETTINGS") + for _, d := range damage.All() { + fmt.Fprintf(out, "%-14s %-14s %s\n", + d.ID, core.ExactBytes(defaultFloor(d)), settingsColumn(d)) + // The sentence under the row rather than in a column of its own. A + // damage id is not self describing the way a format id is - nobody + // reads "zero-head" and knows what comes out - so a list of names + // would say no more than the --damage flag help already says. + fmt.Fprintf(out, " %s\n", d.Detail) + } + fmt.Fprint(out, "\nRun \"tfg damage \" for what one damage takes.\n") + fmt.Fprint(out, "The smallest file is what the default settings need.\n") +} + +func damageUsage(w io.Writer, fs *flag.FlagSet, errOut io.Writer) { + fmt.Fprint(w, `tfg damage - list the ways this build can break a file on purpose. + +A damaged file comes out exactly the size you asked for and is one a reader +refuses, so the manifest records it as expected to be rejected. Use them with +"tfg generate --damage " or with the damage key of a recipe. + +Usage: + tfg damage + tfg damage --json + tfg damage + +Flags: +`) + fs.SetOutput(w) + fs.PrintDefaults() + fs.SetOutput(errOut) +} + +// everyDamageEntry is the whole registry as a script sees it. +func everyDamageEntry() []damageEntry { + all := damage.All() + list := make([]damageEntry, 0, len(all)) + for _, d := range all { + list = append(list, damageEntryFor(d)) + } + return list +} + +// damageCmd answers what this build can break and what each one takes. +// +// It exists because the window drew the settings of a chosen damage from the +// declaration and the command line named the damages and stopped there: to +// learn that zero-head takes bytes, and that bytes runs from 4 to 4096, the +// only way was to type a wrong value and read the refusal. The parity guard +// does not see that gap, because it counts recipe keys, formats and presets and +// a damage parameter is none of the three. Written up in +// docs/CORRUPTION-ARCHITECTURE-2026-09-08.md section 14.5. +func damageCmd(args []string, out, errOut io.Writer) int { + fs := flag.NewFlagSet("damage", flag.ContinueOnError) + fs.SetOutput(errOut) + asJSON := fs.Bool("json", false, "write the list as JSON to standard output") + fs.Usage = func() { damageUsage(errOut, fs, errOut) } + if helpRequested(args) { + damageUsage(out, fs, errOut) + return ExitOK + } + leading, rest := splitLeadingPath(args) + if err := fs.Parse(rest); err != nil { + return ExitUsage + } + + wanted, ok := atMostOneName(leading, fs, errOut) + if !ok { + return ExitUsage + } + + if wanted == "" { + if *asJSON { + return renderJSON(everyDamageEntry(), out, errOut) + } + listDamages(out) + return ExitOK + } + + d, err := damage.Get(wanted) + if err != nil { + // The registry already names the damages it knows, so the message is + // written once rather than here as well. + fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) + return classify(err) + } + if *asJSON { + return renderJSON([]damageEntry{damageEntryFor(d)}, out, errOut) + } + describeOneDamage(d, out) + return ExitOK +} + +// atMostOneName takes the single id a listing command was asked about. +// +// Two names are refused rather than answered about the first. Silence about an +// argument somebody typed is the shape untouchable rule 6 forbids: measured on +// 2026-09-09, "tfg formats png svg" describes png, ignores svg and ends with +// zero, so a script asking about the wrong thing gets a confident answer about +// something else. This command does not repeat that. +func atMostOneName(leading string, fs *flag.FlagSet, errOut io.Writer) (string, bool) { + extra := fs.Args() + switch { + case leading == "" && len(extra) == 0: + return "", true + case leading == "" && len(extra) == 1: + return extra[0], true + case leading != "" && len(extra) == 0: + return leading, true + } + names := append([]string{}, extra...) + if leading != "" { + names = append([]string{leading}, names...) + } + fmt.Fprintf(errOut, "tfg: asked about %s at once, and this describes one at a time. "+ + "Run it again with a single name, or with none for the whole list.\n", strings.Join(names, ", ")) + return "", false +} diff --git a/internal/cli/generate.go b/internal/cli/generate.go index cef196f..94fa1d1 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -77,12 +77,18 @@ func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io. // map: the order damages are applied in is part of what they mean. // The names come from the registry rather than being typed here. The first // version of this line ended "run tfg damage to see what there is" and - // there is no such command - a sentence in shipped help promising + // there was no such command - a sentence in shipped help promising // something that does not exist, which is the class this project calls // prose with an expiry date. Built from Names() it cannot say that again. + // + // The pointer is back on 2026-09-09 because the command now exists, and it + // carries what this line cannot: what each damage does to the bytes, what + // it takes and how small a file it can be given. The names stay beside it + // rather than being replaced by it, so the common case is answered without + // a second command. fs.Var(&g.repeated.damage, "damage", "break the files on purpose, repeatable and applied in order: "+ "--damage zero-head, or --damage zero-head:bytes=16. This build has: "+ - strings.Join(damage.Names(), ", ")) + strings.Join(damage.Names(), ", ")+". Run \"tfg damage\" for what each one does") usage := func(w io.Writer) { fmt.Fprint(w, `tfg generate - produce files. diff --git a/internal/guard/damagecommand_test.go b/internal/guard/damagecommand_test.go new file mode 100644 index 0000000..4dc6e49 --- /dev/null +++ b/internal/guard/damagecommand_test.go @@ -0,0 +1,344 @@ +package guard + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "strconv" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "github.com/donislawdev/TestingFilesGenerator/internal/damage" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" +) + +// The command line says what a damage TAKES, not only that it exists. +// +// This is the other half of TestTheWindowDrawsAFieldForEveryDamageParameter. +// The window drew the settings of a chosen damage from the declaration on the +// day damage arrived, and the command line named the damages and stopped +// there: to learn that zero-head takes bytes, and that bytes runs from 4 to +// 4096, the only way was to type a wrong value and read the refusal. +// +// The parity guard cannot see that gap. It counts recipe keys, formats and +// presets, and a damage parameter is none of the three - so this is a parity +// defect in quality rather than in reach, and it needed a guard of its own. +// Written up in docs/CORRUPTION-ARCHITECTURE-2026-09-08.md section 14.5. +// +// The expectation is REBUILT from the declaration rather than written out. A +// list of strings would prove today's damage and go quiet on the next one, +// which is the lesson the imagedim guard cost: a guard that reconstructs what +// the registry declares covers a field nobody has thought of yet. +func TestTheCommandLineSaysWhatEveryDamageTakes(t *testing.T) { + all := damage.All() + if len(all) == 0 { + t.Fatal("no damage is registered, so this guard would pass against any build") + } + + code, listing, errOut := run(t, "damage") + if code != cli.ExitOK { + t.Fatalf("listing the damages ended with %d: %s", code, errOut) + } + + settings := 0 + for _, d := range all { + if !strings.Contains(listing, d.ID) { + t.Errorf("the list does not name %q:\n%s", d.ID, listing) + } + // The sentence, not only the name. A damage id is not self describing + // the way a format id is - nobody reads "zero-head" and knows what + // comes out - so a list of names alone would say no more than the + // --damage flag help already says. + if !strings.Contains(listing, d.Detail) { + t.Errorf("the list names %q and does not say what it does:\n%s", d.ID, listing) + } + + code, described, errOut := run(t, "damage", d.ID) + if code != cli.ExitOK { + t.Fatalf("describing %q ended with %d: %s", d.ID, code, errOut) + } + for _, p := range d.Parameters { + // What a value may be, asked of the declaration itself. Allowed is + // the sentence the window puts under the same field, so the two + // surfaces cannot describe one setting in two ways. + for _, want := range []string{p.Name, p.Allowed()} { + if !strings.Contains(described, want) { + t.Errorf("%s declares %q and the command line does not say %q:\n%s", + d.ID, p.Name, want, described) + } + } + settings++ + } + } + if settings == 0 { + t.Fatal("no damage declares a setting, so the half that matters proved nothing") + } + + // The detail view has to be a different answer from the list, or the + // second command is a longer way of asking the first. What only it carries + // is the range: with one damage registered there is no other way to tell + // "it honoured the argument" from "it printed everything". + ranged := 0 + for _, d := range all { + _, described, _ := run(t, "damage", d.ID) + for _, p := range d.Parameters { + if strings.Contains(listing, p.Allowed()) { + t.Errorf("the list already carries the range of %s.%s, so asking about "+ + "one damage adds nothing", d.ID, p.Name) + } + if strings.Contains(described, p.Allowed()) { + ranged++ + } + } + } + if ranged == 0 { + t.Fatal("no range reached the detail view, so the comparison above proved nothing") + } + t.Logf("%d damage setting(s) described from the registry", settings) +} + +// The number this command announces as the smallest file is a number a run +// accepts. +// +// The same promise TestTheSmallestSizeIsAcceptedForEveryFormat makes about tfg +// formats, and it is here because that column got it wrong once: until +// 2026-08-04 the MINIMUM of tfg formats was the structural floor of the format +// rather than what a run would take, so pdf announced 3265 and refused it. +// +// The floor is DERIVED from the declaration rather than typed, which is what +// separates this from the engine guards beside it - those name 8 because +// zero-head defaults to eight bytes, and would go quiet if the default moved. +// Pressed through the whole command line rather than through Plan, because the +// claim is about what the tool takes and not about what one function believes. +func TestTheSmallestFileTheDamageCommandAnnouncesIsOneARunAccepts(t *testing.T) { + all := damage.All() + if len(all) == 0 { + t.Fatal("no damage is registered, so this guard would pass against any build") + } + + carrier, err := format.Get("txt") + if err != nil { + t.Fatalf("txt is the format small enough to sit on these floors: %v", err) + } + smallestFile := carrier.SmallestAccepted(format.Request{}) + + for _, d := range all { + floor := announcedFloor(t, d.ID) + if floor < smallestFile { + t.Fatalf("%s says it needs %d B and the smallest txt is %d B, so this "+ + "guard can no longer put the question", d.ID, floor, smallestFile) + } + + at := strconv.FormatInt(floor, 10) + code, _, errOut := run(t, "generate", "--format", "txt", "--size", at, + "--damage", d.ID, "--clean", "--out", t.TempDir()) + if code != cli.ExitOK { + t.Errorf("%s announces %s B as the smallest file and a run of exactly that "+ + "ended with %d: %s", d.ID, at, code, errOut) + } + + // The other end, so this cannot be satisfied by a build that accepts + // everything. One byte under has to be refused, and the refusal has to + // name the number that was announced rather than only saying no. + under := strconv.FormatInt(floor-1, 10) + code, _, errOut = run(t, "generate", "--format", "txt", "--size", under, + "--damage", d.ID, "--clean", "--out", t.TempDir()) + if code == cli.ExitOK { + t.Errorf("%s announces %s B as the smallest file and a run of %s B was accepted", + d.ID, at, under) + } + if !strings.Contains(errOut, at) { + t.Errorf("%s refused %s B without naming the %s B it announced: %s", + d.ID, under, at, errOut) + } + } +} + +// announcedFloor is the smallest file the command prints for one damage, read +// out of the machine readable form. +func announcedFloor(t *testing.T, id string) int64 { + t.Helper() + code, stdout, errOut := run(t, "damage", id, "--json") + if code != cli.ExitOK { + t.Fatalf("asking about %q as JSON ended with %d: %s", id, code, errOut) + } + var entries []struct { + SmallestFileBytes int64 `json:"smallest_file_bytes"` + } + if err := json.Unmarshal([]byte(stdout), &entries); err != nil { + t.Fatalf("the JSON for %q does not parse: %v\n%s", id, err, stdout) + } + if len(entries) != 1 { + t.Fatalf("asking about one damage returned %d entries", len(entries)) + } + return entries[0].SmallestFileBytes +} + +// Every declared setting reaches the machine readable form, filled in. +// +// The keys being present is the easy half and was once the only half: tfg +// formats emptied every value and kept all five keys, so a script would have +// drawn a field with no unit, no default and no help text and nothing would +// have said so. This asks for the values. +func TestTheMachineReadableDamageListCarriesTheWholeDeclaration(t *testing.T) { + code, stdout, errOut := run(t, "damage", "--json") + if code != cli.ExitOK { + t.Fatalf("the machine readable list ended with %d: %s", code, errOut) + } + + var entries []struct { + ID string `json:"id"` + Detail string `json:"detail"` + SmallestFileBytes int64 `json:"smallest_file_bytes"` + Parameters []struct { + Name string `json:"name"` + Kind string `json:"kind"` + Min int64 `json:"min"` + Max int64 `json:"max"` + Unit string `json:"unit"` + Default string `json:"default"` + Detail string `json:"detail"` + } `json:"parameters"` + } + if err := json.Unmarshal([]byte(stdout), &entries); err != nil { + t.Fatalf("the list does not parse: %v\n%s", err, stdout) + } + if len(entries) != len(damage.All()) { + t.Fatalf("the registry holds %d damage(s) and the list carries %d", + len(damage.All()), len(entries)) + } + + checked := 0 + for _, e := range entries { + d, err := damage.Get(e.ID) + if err != nil { + t.Errorf("the list carries %q, which the registry does not know", e.ID) + continue + } + if e.Detail != d.Detail { + t.Errorf("%s: the list says %q and the registry says %q", e.ID, e.Detail, d.Detail) + } + if e.SmallestFileBytes != d.Floor(d.Defaults()) { + t.Errorf("%s: the list says %d B and the declaration says %d B", + e.ID, e.SmallestFileBytes, d.Floor(d.Defaults())) + } + if len(e.Parameters) != len(d.Parameters) { + t.Errorf("%s declares %d setting(s) and the list carries %d", + e.ID, len(d.Parameters), len(e.Parameters)) + continue + } + for i, p := range d.Parameters { + got := e.Parameters[i] + if got.Name != p.Name || got.Kind != string(p.Kind) || got.Default != p.Default { + t.Errorf("%s.%s arrives as name %q kind %q default %q", + e.ID, p.Name, got.Name, got.Kind, got.Default) + } + if got.Detail != p.Detail { + t.Errorf("%s.%s arrives with detail %q, and the declaration says %q", + e.ID, p.Name, got.Detail, p.Detail) + } + if p.Kind == format.PropertyInt && (got.Min != p.Min || got.Max != p.Max) { + t.Errorf("%s.%s runs from %d to %d and arrives as %d to %d", + e.ID, p.Name, p.Min, p.Max, got.Min, got.Max) + } + if got.Unit != p.Unit { + t.Errorf("%s.%s has unit %q and arrives with %q", e.ID, p.Name, p.Unit, got.Unit) + } + checked++ + } + } + if checked == 0 { + t.Fatal("no setting was compared, so this proved nothing") + } +} + +// Every command that gets its own arguments is watched for --help. +// +// commandsTakingHelp is written out by hand, and the comment above it says +// that adding a command without adding it there leaves that command unwatched. +// Nothing held that. Adding tfg damage on 2026-09-09 meant remembering two +// hand kept lists in two files, which is the shape this project has been +// bitten by before - the mutation list went stale on twenty eight entries the +// same way. +// +// The rule is mechanical rather than a list of exceptions. A case in the +// dispatch that hands args[1:] to something takes flags of its own and +// therefore takes --help. version, license and help do not: they answer and +// return, so they are not exempted here, they simply do not match. A list of +// exceptions would have had nine entries against ten commands, and a ratchet +// that is mostly excuse teaches its reader to skip it. +func TestEveryCommandTakingItsOwnArgumentsIsWatchedForHelp(t *testing.T) { + dispatched := commandsInTheDispatch(t) + if len(dispatched) == 0 { + t.Fatal("no dispatching case was found, so this guard would pass against any tree") + } + + watched := map[string]bool{} + for _, cmd := range commandsTakingHelp { + watched[cmd[0]] = true + } + for _, name := range dispatched { + if !watched[name] { + t.Errorf("tfg %s takes its own arguments and is not in commandsTakingHelp, "+ + "so nothing asks whether it answers --help on stdout with code 0", name) + } + } + t.Logf("%d command(s) take their own arguments", len(dispatched)) +} + +// commandsInTheDispatch reads the verbs of cli.Run that hand on their +// arguments, by asking the source rather than by running anything. +func commandsInTheDispatch(t *testing.T) []string { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "../cli/cli.go", nil, 0) + if err != nil { + t.Fatalf("reading the dispatch: %v", err) + } + + var out []string + ast.Inspect(file, func(n ast.Node) bool { + clause, ok := n.(*ast.CaseClause) + if !ok { + return true + } + if !handsOnItsArguments(clause) { + return true + } + for _, expr := range clause.List { + lit, ok := expr.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + name, err := strconv.Unquote(lit.Value) + if err != nil || strings.HasPrefix(name, "-") { + continue + } + out = append(out, name) + } + return true + }) + return out +} + +// handsOnItsArguments reports whether this case passes args[1:] to something, +// which is what having flags of its own looks like from the outside. +func handsOnItsArguments(clause *ast.CaseClause) bool { + found := false + for _, stmt := range clause.Body { + ast.Inspect(stmt, func(n ast.Node) bool { + slice, ok := n.(*ast.SliceExpr) + if !ok { + return true + } + ident, ok := slice.X.(*ast.Ident) + if ok && ident.Name == "args" && slice.High == nil { + found = true + } + return true + }) + } + return found +} diff --git a/internal/guard/exitcodes_test.go b/internal/guard/exitcodes_test.go index bb91115..9f1f7d8 100644 --- a/internal/guard/exitcodes_test.go +++ b/internal/guard/exitcodes_test.go @@ -106,6 +106,9 @@ func TestEveryEndingUsesACodeFromTheTable(t *testing.T) { {"an unknown format", []string{"generate", "--format", "nope", "--size", "1kb", "--out", filepath.Join(dir, "e4")}, cli.ExitFormat}, {"writing over something", []string{"generate", "--format", "txt", "--size", "500", "--out", occupied}, cli.ExitIO}, {"listing the formats", []string{"formats"}, cli.ExitOK}, + {"listing the damages", []string{"damage"}, cli.ExitOK}, + {"a damage nobody registered", []string{"damage", "nope"}, cli.ExitUsage}, + {"two damages at once", []string{"damage", "zero-head", "zero-head"}, cli.ExitUsage}, {"the version", []string{"version"}, cli.ExitOK}, } diff --git a/internal/guard/help_test.go b/internal/guard/help_test.go index 677390d..96d4bc5 100644 --- a/internal/guard/help_test.go +++ b/internal/guard/help_test.go @@ -36,6 +36,7 @@ var commandsTakingHelp = [][]string{ {"verify"}, {"cleanup"}, {"formats"}, + {"damage"}, {"recipe", "fmt"}, {"preset"}, {"preset", "list"},