Skip to content
Open
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
24 changes: 23 additions & 1 deletion cmd/litestream/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> -config PATH\n" +
" or: LITESTREAM_CONFIG=PATH litestream <command>",
}
}
return &usageError{
message: "flags must come after the subcommand",
hint: "litestream <command> [flags]",
}
}

// Usage prints the help screen to STDOUT.
func (m *Main) Usage() {
fmt.Println(`
Expand Down
92 changes: 92 additions & 0 deletions cmd/litestream/main_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
Expand Down Expand Up @@ -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 <command> -config PATH\n or: LITESTREAM_CONFIG=PATH litestream <command>\n"
const genericHint = "Try: litestream <command> [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 <command> [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 <command> [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")
Expand Down
6 changes: 3 additions & 3 deletions cmd/litestream/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Expand Down
Loading