Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
48 changes: 47 additions & 1 deletion cmd/litestream/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"path"
"path/filepath"
"runtime/debug"
"slices"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -230,14 +231,59 @@ 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 misplacedFlagsError(append([]string{cmd}, args...))
}
return fmt.Errorf("litestream %s: unknown command", cmd)
}
}

// commandNames lists the subcommands dispatched by Main.Run.
var commandNames = []string{
"databases", "info", "list", "ltx", "register", "replicate", "reset",
"restore", "start", "status", "stop", "sync", "unregister", "version",
}

// misplacedFlagsError returns the error for flags passed before the
// subcommand, e.g. "litestream -config c.yml databases". Flags are only
// parsed by subcommands, so the hint reorders the user's own arguments into
// a form that can be pasted back. When -config is the misplaced flag it also
// offers LITESTREAM_CONFIG, which is what works for shell aliases.
func misplacedFlagsError(args []string) error {
var cmd, configPath string
var rest, envRest []string // envRest omits the -config flag.
for i := 0; i < len(args); i++ {
arg := args[i]
if cmd == "" && slices.Contains(commandNames, arg) {
cmd = arg
continue
}
rest = append(rest, arg)
if name := strings.TrimLeft(arg, "-"); name == "config" && i+1 < len(args) {
i++
configPath = args[i]
rest = append(rest, configPath)
} else if strings.HasPrefix(name, "config=") {
configPath = strings.TrimPrefix(name, "config=")
} else {
envRest = append(envRest, arg)
}
}
if cmd == "" {
cmd = "<command>"
}

hint := strings.Join(append([]string{"litestream", cmd}, rest...), " ")
if configPath != "" {
// main() prints the hint after "Try: ", so align the alternative under it.
hint += "\n or: " + strings.Join(append([]string{"LITESTREAM_CONFIG=" + configPath, "litestream", cmd}, envRest...), " ")
}
return &usageError{message: "flags must come after the subcommand", hint: hint}
}

// Usage prints the help screen to STDOUT.
func (m *Main) Usage() {
fmt.Println(`
Expand Down
45 changes: 45 additions & 0 deletions cmd/litestream/main_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,51 @@ func TestMainRequiredArgumentErrorsIncludeTryHints(t *testing.T) {
}
}

func TestMainMisplacedFlagsIncludeTryHints(t *testing.T) {
tests := []struct {
name string
args []string
hint string
}{
{
name: "ConfigBeforeCommand",
args: []string{"-config", "./c.yml", "databases"},
hint: "litestream databases -config ./c.yml\n or: LITESTREAM_CONFIG=./c.yml litestream databases",
},
{
name: "ConfigEqualsBeforeCommand",
args: []string{"--config=./c.yml", "restore", "-o", "/tmp/example.db", "s3://bucket/prefix"},
hint: "litestream restore --config=./c.yml -o /tmp/example.db s3://bucket/prefix\n or: LITESTREAM_CONFIG=./c.yml litestream restore -o /tmp/example.db s3://bucket/prefix",
},
{
name: "OtherFlagBeforeCommand",
args: []string{"-replica", "s3://bucket/prefix", "register", "/tmp/example.db"},
hint: "litestream register -replica s3://bucket/prefix /tmp/example.db",
},
{
name: "NoCommand",
args: []string{"-config", "./c.yml"},
hint: "litestream <command> -config ./c.yml\n or: LITESTREAM_CONFIG=./c.yml litestream <command>",
},
}

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)
}
want := "Error: flags must come after the subcommand\nTry: " + tt.hint + "\n"
if stderr != want {
t.Fatalf("unexpected stderr:\n%s\nwant:\n%s", stderr, want)
}
})
}
}

func TestMainHarness(t *testing.T) {
if os.Getenv("LITESTREAM_TEST_MAIN") != "1" {
t.Skip("helper process only")
Expand Down
43 changes: 39 additions & 4 deletions cmd/litestream/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,46 @@ func TestMain_RunHelp(t *testing.T) {
t.Fatalf("Run returned error %v, want %v", err, flag.ErrHelp)
}
})
}

t.Run("UnknownFlag", 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)
func TestMain_RunMisplacedFlags(t *testing.T) {
const want = "flags must come after the subcommand"

t.Run("FlagBeforeCommand", func(t *testing.T) {
for _, args := range [][]string{
{"-config", "litestream.yml", "databases"},
{"-config", "litestream.yml"},
} {
var err error
stdout := captureStdout(t, func() {
err = main.NewMain().Run(context.Background(), args)
})
if err == nil || err.Error() != want {
t.Fatalf("Run(%v) returned error %v, want %q", args, err, want)
} else if stdout != "" {
t.Fatalf("Run(%v) printed usage on stdout:\n%s", args, stdout)
}
}
})

t.Run("HelpFlagStillPrintsUsage", func(t *testing.T) {
for _, arg := range []string{"-h", "-help", "--help"} {
var err error
stdout := captureStdout(t, func() {
err = main.NewMain().Run(context.Background(), []string{arg})
})
if err != nil {
t.Fatalf("Run(%s) returned error: %v", arg, err)
} else if !strings.Contains(stdout, "litestream <command> [arguments]") {
t.Fatalf("Run(%s) did not print usage, got:\n%s", arg, stdout)
}
}
})

t.Run("FlagAfterCommand", func(t *testing.T) {
err := main.NewMain().Run(context.Background(), []string{"databases", "-config", filepath.Join(t.TempDir(), "missing.yml")})
if err == nil || err.Error() == want {
t.Fatalf("Run returned error %v, want config error", err)
}
})
}
Expand Down
Loading