diff --git a/internal/cli/auth.go b/internal/cli/auth.go index a82ef78..f408aca 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -227,6 +227,10 @@ func newAuthCmd() *cobra.Command { cmd := &cobra.Command{ Use: "auth", Short: "Inspect tracebloc authentication state", + // Bare `tracebloc auth` prints help; a mistyped subcommand errors with a + // suggestion instead of silently exiting 0 (#75). + RunE: runGroup, + SuggestionsMinimumDistance: 2, } cmd.AddCommand(newAuthStatusCmd()) return cmd diff --git a/internal/cli/client.go b/internal/cli/client.go index 43aecaf..4277bb0 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -48,6 +48,11 @@ func newClientCmd() *cobra.Command { Long: `Provision a tracebloc client for this machine. Requires sign-in first (` + "`tracebloc login`" + `). To remove tracebloc from this machine, use ` + "`tracebloc delete`" + `.`, + // Bare `tracebloc client` prints help; a mistyped subcommand errors with a + // suggestion instead of silently exiting 0. The hidden `list` is excluded + // from suggestions by SuggestionsFor (#75). + RunE: runGroup, + SuggestionsMinimumDistance: 2, } cmd.AddCommand(newClientCreateCmd(), newClientListCmd(), newClientStatusCmd()) return cmd diff --git a/internal/cli/cluster.go b/internal/cli/cluster.go index 76a0952..5a11945 100644 --- a/internal/cli/cluster.go +++ b/internal/cli/cluster.go @@ -33,6 +33,10 @@ Use ` + "`cluster info`" + ` to verify which cluster, namespace, and client the next ` + "`data ingest`" + ` will target. Useful as a pre-flight before doing anything destructive (e.g. ingesting into the wrong cluster).`, + // Bare `tracebloc cluster` prints help; a mistyped subcommand errors with a + // suggestion instead of silently exiting 0 (#75). + RunE: runGroup, + SuggestionsMinimumDistance: 2, } cmd.AddCommand(newClusterInfoCmd()) diff --git a/internal/cli/data.go b/internal/cli/data.go index cca5bdc..6a60440 100644 --- a/internal/cli/data.go +++ b/internal/cli/data.go @@ -46,6 +46,10 @@ locally first. ` + "`tracebloc cluster info`" + ` is the pre-flight you'd typically run before the first ingest.`, + // A bare `tracebloc data` prints help; a mistyped subcommand errors with a + // suggestion instead of silently exiting 0 (#75). + RunE: runGroup, + SuggestionsMinimumDistance: 2, } cmd.AddCommand(newDataIngestCmd()) cmd.AddCommand(newDataListCmd()) diff --git a/internal/cli/group_test.go b/internal/cli/group_test.go new file mode 100644 index 0000000..ddce386 --- /dev/null +++ b/internal/cli/group_test.go @@ -0,0 +1,101 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +// The parent "group" commands (data, cluster, auth, client) are runnable via +// runGroup so a mistyped subcommand is a hard error with a suggestion, instead +// of cobra's non-runnable default of printing help and exiting 0 — which +// silently swallowed typos like `tracebloc data ingst`. See #75. + +// execGroup runs the CLI with args and returns the resolved exit code + the +// text the user would see. The root sets SilenceErrors, so (like main.go) the +// error is RETURNED rather than written to the buffer — fold its message in so +// assertions see what main.go prints via `Error: `. +func execGroup(t *testing.T, args ...string) (int, string) { + t.Helper() + root := NewRootCmd(BuildInfo{Version: "test"}) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(args) + err := root.Execute() + seen := out.String() + if err != nil { + seen += "\n" + err.Error() + } + return ExitCodeFromError(err), seen +} + +// A mistyped subcommand under each group must exit non-zero (not 0). This is +// the core #75 regression: before the fix, all of these printed help + exit 0. +func TestGroup_UnknownSubcommand_Errors(t *testing.T) { + cases := [][]string{ + {"data", "ingst"}, + {"dataset", "pus"}, // the `dataset` alias path from the issue + {"cluster", "inf"}, + {"auth", "stats"}, + {"client", "lst"}, + } + for _, args := range cases { + t.Run(strings.Join(args, " "), func(t *testing.T) { + code, out := execGroup(t, args...) + if code == 0 { + t.Fatalf("a mistyped subcommand must not exit 0; got exit 0:\n%s", out) + } + if !strings.Contains(out, "unknown command") { + t.Errorf("expected an \"unknown command\" error, got:\n%s", out) + } + }) + } +} + +// The nearest-match hint fires for a close typo (Levenshtein <= 2), and the +// suggested name is a real, non-hidden sibling command. +func TestGroup_UnknownSubcommand_Suggests(t *testing.T) { + cases := []struct { + args []string + suggest string + }{ + {[]string{"data", "ingst"}, "ingest"}, + {[]string{"cluster", "inf"}, "info"}, + {[]string{"cluster", "doctr"}, "doctor"}, + } + for _, c := range cases { + t.Run(strings.Join(c.args, " "), func(t *testing.T) { + _, out := execGroup(t, c.args...) + if !strings.Contains(out, "Did you mean this?") || !strings.Contains(out, c.suggest) { + t.Errorf("expected a suggestion of %q, got:\n%s", c.suggest, out) + } + }) + } +} + +// The hidden `client list` (Hidden per Rev-9 §7.10) must NOT be offered as a +// suggestion even though `lst` is one edit from `list` — SuggestionsFor skips +// unavailable commands. +func TestGroup_HiddenSubcommand_NotSuggested(t *testing.T) { + _, out := execGroup(t, "client", "lst") + if strings.Contains(out, "list") { + t.Errorf("hidden `client list` must not be suggested:\n%s", out) + } +} + +// A bare group (no subcommand) still prints its help and exits 0 — runnable +// must not change the friendly bare-group behavior. +func TestGroup_Bare_StillHelpsExitZero(t *testing.T) { + for _, group := range []string{"data", "cluster", "auth", "client"} { + t.Run(group, func(t *testing.T) { + code, out := execGroup(t, group) + if code != 0 { + t.Fatalf("bare `%s` should exit 0, got %d:\n%s", group, code, out) + } + if strings.TrimSpace(out) == "" { + t.Errorf("bare `%s` should print help, got empty output", group) + } + }) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f4d395f..54c7851 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,6 +7,8 @@ package cli import ( + "errors" + "fmt" "io" "os" "strings" @@ -121,6 +123,39 @@ Helm, no YAML, no kubectl needed.`, return root } +// runGroup is the RunE for a parent "group" command (data, cluster, auth, +// client). A bare `tracebloc ` prints its help and exits 0; a mistyped +// subcommand (`tracebloc data ingst`) is a hard error (exit 1) with a +// nearest-match suggestion. +// +// Why a RunE at all: a parent command with subcommands but no Run/RunE is +// "not runnable", and cobra short-circuits a non-runnable command to +// flag.ErrHelp BEFORE it validates args (command.go: `if !c.Runnable() { +// return flag.ErrHelp }` precedes ValidateArgs). So `data ingst` printed help +// and exited 0, silently swallowing the typo. Giving the group a RunE makes it +// runnable, so the unknown token reaches here instead. (`cobra.NoArgs` does +// NOT fix this — it's an arg validator, never reached on a non-runnable +// command.) The root command needs none of this: as the parent-less command +// its default legacyArgs already errors on an unknown token. See #75. +func runGroup(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + // Mirror cobra's own "unknown command" wording (what the root emits) so a + // group typo reads identically. Suggestions come from SuggestionsFor, which + // skips hidden commands (e.g. the hidden `client list`) and keys off the + // group's SuggestionsMinimumDistance. + msg := fmt.Sprintf("unknown command %q for %q", args[0], cmd.CommandPath()) + if suggestions := cmd.SuggestionsFor(args[0]); len(suggestions) > 0 { + msg += "\n\nDid you mean this?" + for _, s := range suggestions { + msg += "\n\t" + s + } + } + msg += fmt.Sprintf("\n\nRun '%s --help' for the available commands.", cmd.CommandPath()) + return &exitError{code: 1, err: errors.New(msg)} +} + // printerFor builds a ui.Printer for a command's stdout, honoring the // persistent --plain flag. Color / TTY / NO_COLOR auto-detection lives // in ui.New; --plain just forces it off. Commands call this at the top