diff --git a/cli/commands/unknown_command_test.go b/cli/commands/unknown_command_test.go new file mode 100644 index 000000000..3082c9673 --- /dev/null +++ b/cli/commands/unknown_command_test.go @@ -0,0 +1,169 @@ +package commands + +import ( + "strings" + "testing" + + "miren.dev/mflags" + "miren.dev/runtime/pkg/labs" +) + +// dispatchErr builds the full dispatcher and returns the error Execute produces, +// so that tests can assert on what the user sees when a command is mistyped. +func dispatchErr(t *testing.T, args ...string) error { + t.Helper() + + d := mflags.NewDispatcher("miren") + RegisterAll(d) + + return d.Execute(args) +} + +// TestUnknownCommandSuggests guards MIR-1823: a mistyped command must name the +// word that went wrong and point at the real one, rather than reporting a flag +// parsing failure. +func TestUnknownCommandSuggests(t *testing.T) { + labs.EnableAll() + + cases := []struct { + args []string + want []string + notWant []string + }{ + { + args: []string{"depoy"}, + want: []string{`unknown command "depoy"`, "Did you mean?", "deploy", "miren --help"}, + notWant: []string{"error parsing flags", "["}, + }, + { + // The old message reported "error parsing flags: unexpected + // arguments: [lst]" here. + args: []string{"app", "lst"}, + want: []string{`unknown command "lst" for "miren app"`, "Did you mean?", "list", "miren app --help"}, + notWant: []string{"error parsing flags", "["}, + }, + { + // Sections tolerate unknown flags, so this used to print help and + // exit 0 with no sign that anything was wrong. + args: []string{"runner", "upgrde"}, + want: []string{`unknown command "upgrde" for "miren runner"`, "upgrade"}, + notWant: []string{"error parsing flags"}, + }, + { + // Nothing is close to this, so no guess should be offered. + args: []string{"zzzznotacommand"}, + want: []string{`unknown command "zzzznotacommand"`}, + notWant: []string{"Did you mean?", "error parsing flags"}, + }, + { + // "version" has no sub-commands, so there is nothing to suggest — + // but the Go slice syntax and the flag-parsing prefix are still gone. + args: []string{"version", "foo"}, + want: []string{`unexpected argument "foo"`}, + notWant: []string{"error parsing flags", "[foo]"}, + }, + } + + for _, c := range cases { + t.Run(strings.Join(c.args, "_"), func(t *testing.T) { + err := dispatchErr(t, c.args...) + if err == nil { + t.Fatalf("Execute(%v) returned no error", c.args) + } + + got := err.Error() + for _, want := range c.want { + if !strings.Contains(got, want) { + t.Errorf("expected %q in error, got:\n%s", want, got) + } + } + for _, notWant := range c.notWant { + if strings.Contains(got, notWant) { + t.Errorf("did not expect %q in error, got:\n%s", notWant, got) + } + } + }) + } +} + +// TestUnknownFlagSuggests covers the same treatment for a mistyped flag name. +func TestUnknownFlagSuggests(t *testing.T) { + labs.EnableAll() + + cases := []struct { + args []string + want []string + notWant []string + }{ + { + args: []string{"deploy", "--aap", "x"}, + want: []string{"unknown flag: --aap", "Did you mean?", "--app"}, + notWant: []string{"error parsing flags"}, + }, + { + args: []string{"app", "list", "--forma", "json"}, + want: []string{"unknown flag: --forma", "--format"}, + notWant: []string{"error parsing flags"}, + }, + { + // A single letter is too little to guess from. + args: []string{"app", "list", "-Q"}, + want: []string{"unknown flag: -Q"}, + notWant: []string{"Did you mean?"}, + }, + { + // No command word was typed at all, so there is nothing to call an + // unknown command — name the flag instead. + args: []string{"--bogus"}, + want: []string{"unknown flag: --bogus"}, + notWant: []string{`unknown command ""`}, + }, + } + + for _, c := range cases { + t.Run(strings.Join(c.args, "_"), func(t *testing.T) { + err := dispatchErr(t, c.args...) + if err == nil { + t.Fatalf("Execute(%v) returned no error", c.args) + } + + got := err.Error() + for _, want := range c.want { + if !strings.Contains(got, want) { + t.Errorf("expected %q in error, got:\n%s", want, got) + } + } + for _, notWant := range c.notWant { + if strings.Contains(got, notWant) { + t.Errorf("did not expect %q in error, got:\n%s", notWant, got) + } + } + }) + } +} + +// TestKnownCommandsAreNotMistakenForTypos pins the inputs the new check must +// leave alone: help keywords, pass-through arguments, and global flags that +// take a value. +func TestKnownCommandsAreNotMistakenForTypos(t *testing.T) { + labs.EnableAll() + + cases := [][]string{ + {"app", "--help"}, + {"app", "help"}, + {"runner", "help"}, + {"help", "runner"}, + {"-C", "prod", "auth", "help"}, + {"app", "run", "echo", "hello"}, + } + + for _, args := range cases { + t.Run(strings.Join(args, "_"), func(t *testing.T) { + if err := dispatchErr(t, args...); err != nil { + if strings.Contains(err.Error(), "unknown command") { + t.Errorf("Execute(%v) wrongly reported a typo: %v", args, err) + } + } + }) + } +} diff --git a/go.mod b/go.mod index b489951a0..d5d554bc1 100644 --- a/go.mod +++ b/go.mod @@ -107,7 +107,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 k8s.io/klog/v2 v2.130.1 miren.dev/lbd v0.0.0-20260224020427-8914d8db2233 - miren.dev/mflags v0.0.0-20260910225849-7704913d5c9c + miren.dev/mflags v0.0.0-20260913023833-b11db8e63177 modernc.org/sqlite v1.45.0 sigs.k8s.io/knftables v0.0.21 ) diff --git a/go.sum b/go.sum index 9ca6bbd5a..c85ed972c 100644 --- a/go.sum +++ b/go.sum @@ -1955,8 +1955,8 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= miren.dev/lbd v0.0.0-20260224020427-8914d8db2233 h1:9DxH7Dhnmu7hn1OA2JC5fHpLuVgAqBySws9GLNssLl4= miren.dev/lbd v0.0.0-20260224020427-8914d8db2233/go.mod h1:+x9fy2p45csBnGUJdqxCUmzlUTCipoVDbv6zIapTgDA= -miren.dev/mflags v0.0.0-20260910225849-7704913d5c9c h1:x4XxiRPIEh64GV1DzDaxGoiMqj+9qYCOnFBW2h+L3Vs= -miren.dev/mflags v0.0.0-20260910225849-7704913d5c9c/go.mod h1:G1eQ/upWVdO6BGT6dlh5Yqjt+9ncH5RUAKX6UKi1F9Q= +miren.dev/mflags v0.0.0-20260913023833-b11db8e63177 h1:7w7JpoGLuFwv3WQHDeYQHyruWA6y+SLNuZIO4LOZggk= +miren.dev/mflags v0.0.0-20260913023833-b11db8e63177/go.mod h1:G1eQ/upWVdO6BGT6dlh5Yqjt+9ncH5RUAKX6UKi1F9Q= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=