diff --git a/cmd/litestream/main.go b/cmd/litestream/main.go index ecb383e92..2d44c8ffb 100644 --- a/cmd/litestream/main.go +++ b/cmd/litestream/main.go @@ -230,14 +230,36 @@ func (m *Main) Run(ctx context.Context, args []string) (err error) { if cmd == "help" || cmd == "-h" || cmd == "-help" || cmd == "--help" { m.Usage() return nil - } else if cmd == "" || strings.HasPrefix(cmd, "-") { + } else if cmd == "" { m.Usage() return flag.ErrHelp + } else if strings.HasPrefix(cmd, "-") { + return misplacedFlagError(cmd) } return fmt.Errorf("litestream %s: unknown command", cmd) } } +// misplacedFlagError returns a usage error for a flag that appears before the +// subcommand, e.g. "litestream -config c.yml databases". Only the subcommands +// parse flags, so the hint shows where the flag belongs rather than guessing +// the intended command. Shell aliases that lead with -config are pointed at +// LITESTREAM_CONFIG, which works in any position. +func misplacedFlagError(arg string) error { + name := strings.TrimLeft(strings.SplitN(arg, "=", 2)[0], "-") + if name == "config" { + return &usageError{ + message: "flags must come after the subcommand", + hint: "litestream -config PATH\n" + + " or: LITESTREAM_CONFIG=PATH litestream ", + } + } + return &usageError{ + message: "flags must come after the subcommand", + hint: "litestream [flags]", + } +} + // Usage prints the help screen to STDOUT. func (m *Main) Usage() { fmt.Println(` diff --git a/cmd/litestream/main_cli_test.go b/cmd/litestream/main_cli_test.go index 94282548c..48f5ed574 100644 --- a/cmd/litestream/main_cli_test.go +++ b/cmd/litestream/main_cli_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "os/exec" + "path/filepath" "strings" "testing" ) @@ -95,6 +96,97 @@ func TestMainRequiredArgumentErrorsIncludeTryHints(t *testing.T) { } } +func TestMainFlagPlacement(t *testing.T) { + const message = "Error: flags must come after the subcommand\n" + const configHint = "Try: litestream -config PATH\n or: LITESTREAM_CONFIG=PATH litestream \n" + const genericHint = "Try: litestream [flags]\n" + + t.Run("FlagBeforeCommand", func(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "Config", + args: []string{"-config", "/etc/litestream.yml", "databases"}, + want: message + configHint, + }, + { + name: "ConfigWithEquals", + args: []string{"--config=/etc/litestream.yml", "databases"}, + want: message + configHint, + }, + { + // The invocation from the review: "status" may be the value + // of -socket and "info" the intended command, so no hint can + // name the command without guessing. + name: "Socket", + args: []string{"-socket", "status", "info"}, + want: message + genericHint, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout, stderr, exitCode := runLitestreamMain(t, tt.args...) + if exitCode != 1 { + t.Fatalf("exit code=%d, want 1", exitCode) + } + if stdout != "" { + t.Fatalf("expected empty stdout, got:\n%s", stdout) + } + if stderr != tt.want { + t.Fatalf("unexpected stderr:\n%s\nwant:\n%s", stderr, tt.want) + } + }) + } + }) + + // Each help flag starts with "-", so each one has to keep reaching the + // help branch rather than falling through to the misplaced-flag branch. + t.Run("ExplicitHelp", func(t *testing.T) { + for _, arg := range []string{"-h", "-help", "--help"} { + t.Run(arg, func(t *testing.T) { + stdout, stderr, exitCode := runLitestreamMain(t, arg) + if exitCode != 0 { + t.Fatalf("exit code=%d, want 0\nstderr:\n%s", exitCode, stderr) + } + if stderr != "" { + t.Fatalf("expected empty stderr, got:\n%s", stderr) + } + if !strings.Contains(stdout, "litestream [arguments]") { + t.Fatalf("expected usage on stdout, got:\n%s", stdout) + } + }) + } + }) + + t.Run("NoArguments", func(t *testing.T) { + stdout, stderr, exitCode := runLitestreamMain(t) + if exitCode != 1 { + t.Fatalf("exit code=%d, want 1", exitCode) + } + if stderr != "" { + t.Fatalf("expected empty stderr, got:\n%s", stderr) + } + if !strings.Contains(stdout, "litestream [arguments]") { + t.Fatalf("expected usage on stdout, got:\n%s", stdout) + } + }) + + t.Run("FlagAfterCommand", func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "missing.yml") + _, stderr, exitCode := runLitestreamMain(t, "databases", "-config", configPath) + if exitCode != 1 { + t.Fatalf("exit code=%d, want 1", exitCode) + } + if strings.Contains(stderr, "flags must come after the subcommand") { + t.Fatalf("correctly positioned flag reported as misplaced:\n%s", stderr) + } + }) +} + func TestMainHarness(t *testing.T) { if os.Getenv("LITESTREAM_TEST_MAIN") != "1" { t.Skip("helper process only") diff --git a/cmd/litestream/main_test.go b/cmd/litestream/main_test.go index d90fb0cec..64f73c9b7 100644 --- a/cmd/litestream/main_test.go +++ b/cmd/litestream/main_test.go @@ -54,10 +54,10 @@ func TestMain_RunHelp(t *testing.T) { } }) - t.Run("UnknownFlag", func(t *testing.T) { + t.Run("MisplacedFlag", func(t *testing.T) { err := main.NewMain().Run(context.Background(), []string{"-config", "litestream.yml"}) - if !errors.Is(err, flag.ErrHelp) { - t.Fatalf("Run returned error %v, want %v", err, flag.ErrHelp) + if err == nil || err.Error() != "flags must come after the subcommand" { + t.Fatalf("Run returned error %v, want misplaced flag error", err) } }) }