Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
101 changes: 101 additions & 0 deletions internal/cli/group_test.go
Original file line number Diff line number Diff line change
@@ -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: <err>`.
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)
}
})
}
}
35 changes: 35 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
package cli

import (
"errors"
"fmt"
"io"
"os"
"strings"
Expand Down Expand Up @@ -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 <group>` 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
Expand Down
Loading