diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d83068 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +coverage.out diff --git a/argument.go b/argument.go index 42cf53b..9ca9e1e 100644 --- a/argument.go +++ b/argument.go @@ -29,6 +29,10 @@ func newArgument(name, description string, options ...option.Option[*Argument]) return nil, errors.Wrapf(err, "building argument %q", name) } + if err := argument.validateConfig(); err != nil { + return nil, errors.Wrapf(err, "invalid argument %q", name) + } + return argument, nil } diff --git a/argument_config_validation.go b/argument_config_validation.go new file mode 100644 index 0000000..edbce02 --- /dev/null +++ b/argument_config_validation.go @@ -0,0 +1,19 @@ +package cli + +import ( + "strings" + + "github.com/bobg/errors" +) + +func (a *Argument) validateConfig() error { + if len(strings.Fields(a.name)) > 1 { + return errors.Errorf("argument name %q must be a single token", a.name) + } + + if a.name == "" { + return errors.New("argument name cannot be empty") + } + + return nil +} diff --git a/argument_config_validation_test.go b/argument_config_validation_test.go new file mode 100644 index 0000000..7fe9ed7 --- /dev/null +++ b/argument_config_validation_test.go @@ -0,0 +1,27 @@ +package cli + +import ( + "testing" + + "github.com/broothie/test" +) + +func TestArgument_validateConfig(t *testing.T) { + t.Run("empty name", func(t *testing.T) { + arg := &Argument{name: ""} + err := arg.validateConfig() + test.ErrorMessageIs(t, err, "argument name cannot be empty") + }) + + t.Run("multiple tokens", func(t *testing.T) { + arg := &Argument{name: "invalid argument name"} + err := arg.validateConfig() + test.ErrorMessageIs(t, err, `argument name "invalid argument name" must be a single token`) + }) + + t.Run("valid name", func(t *testing.T) { + arg := &Argument{name: "valid-arg"} + err := arg.validateConfig() + test.NoError(t, err) + }) +} diff --git a/argument_input_validation_test.go b/argument_input_validation_test.go new file mode 100644 index 0000000..2cf44a1 --- /dev/null +++ b/argument_input_validation_test.go @@ -0,0 +1,17 @@ +package cli + +import ( + "testing" + + "github.com/broothie/test" +) + +func TestArgument_validateInput(t *testing.T) { + arg, err := newArgument("test-arg", "Test arg.") + test.MustNoError(t, err) + + test.ErrorMessageIs(t, arg.validateInput(), `argument "test-arg": argument missing value`) + + arg.value = "something" + test.NoError(t, arg.validateInput()) +} diff --git a/cli_test.go b/cli_test.go index 543f510..90117d1 100644 --- a/cli_test.go +++ b/cli_test.go @@ -42,7 +42,7 @@ func Test_git(t *testing.T) { called() gitDir, err := FlagValue[string](ctx, "git-dir") - test.Nil(t, err) + test.NoError(t, err) test.Equal(t, "/path/to/something", gitDir) return nil @@ -57,7 +57,7 @@ func Test_git(t *testing.T) { called() gitDir, err := FlagValue[string](ctx, "git-dir") - test.Nil(t, err) + test.NoError(t, err) test.Equal(t, "/path/to/something", gitDir) return nil @@ -82,7 +82,7 @@ func Test_git(t *testing.T) { called() message, err := FlagValue[string](ctx, "message") - test.Nil(t, err) + test.NoError(t, err) test.Equal(t, "a commit message", message) return nil @@ -97,11 +97,11 @@ func Test_git(t *testing.T) { called() isAll, err := FlagValue[bool](ctx, "all") - test.Nil(t, err) + test.NoError(t, err) test.False(t, isAll) message, err := FlagValue[string](ctx, "message") - test.Nil(t, err) + test.NoError(t, err) test.Equal(t, "a commit message", message) return nil @@ -116,11 +116,11 @@ func Test_git(t *testing.T) { called() isAll, err := FlagValue[bool](ctx, "all") - test.Nil(t, err) + test.NoError(t, err) test.False(t, isAll) message, err := FlagValue[string](ctx, "message") - test.Nil(t, err) + test.NoError(t, err) test.Equal(t, "a commit message", message) return nil @@ -135,11 +135,11 @@ func Test_git(t *testing.T) { called() isAll, err := FlagValue[bool](ctx, "all") - test.Nil(t, err) + test.NoError(t, err) test.True(t, isAll) message, err := FlagValue[string](ctx, "message") - test.Nil(t, err) + test.NoError(t, err) test.Equal(t, "a commit message", message) return nil @@ -154,7 +154,7 @@ func Test_git(t *testing.T) { called() branch, err := ArgValue[string](ctx, "branch") - test.Nil(t, err) + test.NoError(t, err) test.Equal(t, "some-branch", branch) return nil @@ -169,11 +169,11 @@ func Test_git(t *testing.T) { called() branch, err := ArgValue[string](ctx, "branch") - test.Nil(t, err) + test.NoError(t, err) test.Equal(t, "some-branch", branch) isNewBranch, err := FlagValue[bool](ctx, "new-branch") - test.Nil(t, err) + test.NoError(t, err) test.True(t, isNewBranch) return nil @@ -188,7 +188,7 @@ func Test_git(t *testing.T) { called() globalGitignore, err := FlagValue[string](ctx, "global-gitignore") - test.Nil(t, err) + test.NoError(t, err) test.Equal(t, globalGitignore, "path/to/some/.gitignore") return nil } @@ -228,7 +228,7 @@ func Test_git(t *testing.T) { SetHandler(lo.IfF(testCase.gitHandler != nil, func() Handler { return testCase.gitHandler(t) }).Else(nil)), ) - test.MustNoError(t, err) + test.NoError(t, err) test.Nil(t, command.Run(context.TODO(), testCase.rawArgs)) }) } diff --git a/command_config_validation.go b/command_config_validation.go index 70d13d0..ac7cc53 100644 --- a/command_config_validation.go +++ b/command_config_validation.go @@ -1,8 +1,6 @@ package cli -import ( - "github.com/bobg/errors" -) +import "github.com/bobg/errors" func (c *Command) validateConfig() error { validations := []func() error{ diff --git a/command_test.go b/command_test.go index c175251..68046a3 100644 --- a/command_test.go +++ b/command_test.go @@ -1,6 +1,10 @@ package cli -import "os" +import ( + "context" + "fmt" + "os" +) func ExampleNewCommand() { command, _ := NewCommand("server", "An http server.", @@ -38,3 +42,22 @@ func ExampleNewCommand() { // --port -p Port to run server on. (type: int, default: $PORT, "3000") // --auth-required Whether to require authentication. (type: bool, default: "true") } + +func ExampleRun() { + oldArgs := os.Args + os.Args = []string{"echo", "hello"} + defer func() { os.Args = oldArgs }() + + Run("echo", "Echo the arguments.", + AddArg("arg", "The argument to echo."), + SetHandler(func(ctx context.Context) error { + arg, _ := ArgValue[string](ctx, "arg") + + fmt.Println(arg) + return nil + }), + ) + + // Output: + // hello +} diff --git a/context.go b/context.go index 8a88dc8..622382a 100644 --- a/context.go +++ b/context.go @@ -62,7 +62,9 @@ func ArgValue[T any](ctx context.Context, name string) (T, error) { return arg.defaultValue.(T), nil } -var commandContextKey struct{} +type commandContextKeyType struct{} + +var commandContextKey = commandContextKeyType{} func (c *Command) onContext(parent context.Context) context.Context { return context.WithValue(parent, commandContextKey, c) diff --git a/error.go b/exit.go similarity index 58% rename from error.go rename to exit.go index bf062ac..7096b2b 100644 --- a/error.go +++ b/exit.go @@ -8,18 +8,22 @@ import ( "github.com/bobg/errors" ) +// ExitError is an error that causes the program to exit with a given status code. type ExitError struct { Code int } +// Error implements the error interface. func (e ExitError) Error() string { return fmt.Sprintf("exit status %d", e.Code) } -func ExitCode(code int) ExitError { - return ExitError{Code: code} +// ExitCode returns an ExitError with the given code. +func ExitCode(code int) *ExitError { + return &ExitError{Code: code} } +// ExitWithError exits the program with an error. func ExitWithError(err error) { fmt.Println(err) @@ -27,7 +31,7 @@ func ExitWithError(err error) { os.Exit(exitErr.Code) } else if exitErr := new(exec.ExitError); errors.As(err, &exitErr) { os.Exit(exitErr.ExitCode()) - } else { + } else if err != nil { os.Exit(1) } } diff --git a/exit_test.go b/exit_test.go new file mode 100644 index 0000000..1f07b63 --- /dev/null +++ b/exit_test.go @@ -0,0 +1,37 @@ +package cli + +import ( + "os" + "os/exec" + "testing" + + "github.com/broothie/test" +) + +func TestExitError_Error(t *testing.T) { + err := &ExitError{Code: 2} + test.Equal(t, "exit status 2", err.Error()) +} + +func TestExitCode(t *testing.T) { + err := ExitCode(3) + test.Equal(t, 3, err.Code) +} + +func TestExitWithError(t *testing.T) { + if os.Getenv("TEST_EXIT") == "1" { + ExitWithError(ExitCode(4)) + return + } + + // Test ExitError + cmd := exec.Command(os.Args[0], "-test.run=TestExitWithError") + cmd.Env = append(os.Environ(), "TEST_EXIT=1") + err := cmd.Run() + + if exitErr, ok := err.(*exec.ExitError); ok { + test.Equal(t, 4, exitErr.ExitCode()) + } else { + t.Errorf("expected ExitError, got %v", err) + } +} diff --git a/flag.go b/flag.go index ebd76f4..54ed46c 100644 --- a/flag.go +++ b/flag.go @@ -39,6 +39,10 @@ func newFlag(name, description string, options ...option.Option[*Flag]) (*Flag, return nil, errors.Wrapf(err, "building flag %q", name) } + if err := flag.validateConfig(); err != nil { + return nil, errors.Wrapf(err, "invalid flag %q", name) + } + return flag, nil } diff --git a/flag_config_validation.go b/flag_config_validation.go new file mode 100644 index 0000000..fc5b650 --- /dev/null +++ b/flag_config_validation.go @@ -0,0 +1,19 @@ +package cli + +import ( + "strings" + + "github.com/bobg/errors" +) + +func (f *Flag) validateConfig() error { + if len(strings.Fields(f.name)) > 1 { + return errors.Errorf("flag name %q must be a single token", f.name) + } + + if f.name == "" { + return errors.New("flag name cannot be empty") + } + + return nil +} diff --git a/flag_config_validation_test.go b/flag_config_validation_test.go new file mode 100644 index 0000000..58ec6a0 --- /dev/null +++ b/flag_config_validation_test.go @@ -0,0 +1,27 @@ +package cli + +import ( + "testing" + + "github.com/broothie/test" +) + +func TestFlag_validateConfig(t *testing.T) { + t.Run("empty name", func(t *testing.T) { + flag := &Flag{name: ""} + err := flag.validateConfig() + test.ErrorMessageIs(t, err, "flag name cannot be empty") + }) + + t.Run("multiple tokens", func(t *testing.T) { + flag := &Flag{name: "invalid flag name"} + err := flag.validateConfig() + test.ErrorMessageIs(t, err, `flag name "invalid flag name" must be a single token`) + }) + + t.Run("valid name", func(t *testing.T) { + flag := &Flag{name: "valid-flag"} + err := flag.validateConfig() + test.NoError(t, err) + }) +} diff --git a/flag_options.go b/flag_options.go index c77b78b..b6a6350 100644 --- a/flag_options.go +++ b/flag_options.go @@ -1,8 +1,6 @@ package cli -import ( - "github.com/broothie/option" -) +import "github.com/broothie/option" // AddFlagAlias adds an alias to the flag. func AddFlagAlias(alias string) option.Func[*Flag] { diff --git a/flag_test.go b/flag_test.go index 70b2458..74e568c 100644 --- a/flag_test.go +++ b/flag_test.go @@ -16,7 +16,7 @@ func TestCommand_flagsUpToRoot(t *testing.T) { ), ) - test.Nil(t, err) + test.NoError(t, err) flags := command.subCommands[0].flagsUpToRoot() flagNames := lo.Map(flags, func(flag *Flag, _ int) string { return flag.name }) diff --git a/go.mod b/go.mod index 665bf0c..77ba205 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/bobg/errors v1.1.0 github.com/broothie/option v0.1.0 - github.com/broothie/test v0.1.8 + github.com/broothie/test v0.1.9 github.com/samber/lo v1.49.1 ) diff --git a/go.sum b/go.sum index 1b934c1..8b834be 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/bobg/errors v1.1.0 h1:gsVanPzJMpZQpwY+27/GQYElZez5CuMYwiIpk2A3RGw= github.com/bobg/errors v1.1.0/go.mod h1:Q4775qBZpnte7EGFJqmvnlB1U4pkI1XmU3qxqdp7Zcc= github.com/broothie/option v0.1.0 h1:5l6qdv9g1Ajxn7821brKVzOZxIjlVB0gA1MU8QbW8Fw= github.com/broothie/option v0.1.0/go.mod h1:doEn1r1TpaaBJRdHLZlsdjvnrnH0u1WW+FZA018hE2g= -github.com/broothie/test v0.1.8 h1:II6ZE0z2ZV0y4GJlLl/oBinG6X58mwyioSd/jxNMea0= -github.com/broothie/test v0.1.8/go.mod h1:txzDcP9OHro3y7goC+ZznIkASy8NJks4dUcdbwsP0HM= +github.com/broothie/test v0.1.9 h1:YO+pAEssBJPvJEWSvl2dSjhsUFm9xszqOXFVEfiI33Q= +github.com/broothie/test v0.1.9/go.mod h1:txzDcP9OHro3y7goC+ZznIkASy8NJks4dUcdbwsP0HM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= diff --git a/help_test.go b/help_test.go index 9e418f5..f556f41 100644 --- a/help_test.go +++ b/help_test.go @@ -37,26 +37,26 @@ func TestCommand_renderHelp(t *testing.T) { ), ) - test.Nil(t, err) + test.NoError(t, err) buffer := new(bytes.Buffer) - test.Nil(t, command.renderHelp(buffer)) + test.NoError(t, command.renderHelp(buffer)) test.Equal(t, heredoc.Doc(` test v1.2.3-rc10: test command - + Usage: test [flags] [] - + Arguments: some arg (type: time.Time) [] another arg (type: int, default: "123") - + Flags: --help Print help. (type: bool, default: "false") --some-flag --a-flag -s some flag (type: cli.CustomType, default: "field=\"field default\"") - + `), buffer.String(), ) @@ -74,25 +74,25 @@ func TestCommand_renderHelp(t *testing.T) { AddSubCmd("some-command", "some command"), ) - test.Nil(t, err) + test.NoError(t, err) buffer := new(bytes.Buffer) - test.Nil(t, command.renderHelp(buffer)) + test.NoError(t, command.renderHelp(buffer)) test.Equal(t, heredoc.Doc(` test v1.2.3-rc10: test command - + Usage: test [flags] [sub-command] - + Sub-command: some-command: some command - + Flags: --help Print help. (type: bool, default: "false") --some-flag --a-flag -s some flag (type: cli.CustomType, default: "field=\"field default\"") - + `), buffer.String(), ) @@ -109,25 +109,25 @@ func TestCommand_renderHelp(t *testing.T) { ), ) - test.Nil(t, err) + test.NoError(t, err) buffer := new(bytes.Buffer) - test.Nil(t, command.subCommands[0].renderHelp(buffer)) + test.NoError(t, command.subCommands[0].renderHelp(buffer)) test.Equal(t, heredoc.Doc(` test v1.2.3-rc10: test command - + Usage: test some-command [flags] - + Arguments: some arg (type: string) - + Flags: --some-flag some flag (type: string, default: "") --inherited inherited (type: string, default: "") - + `), buffer.String(), ) diff --git a/options_test.go b/options_test.go index 5ee6c1a..cbf0da9 100644 --- a/options_test.go +++ b/options_test.go @@ -29,7 +29,7 @@ func Test_options(t *testing.T) { SetHandler(func(context.Context) error { return nil }), ) - test.Nil(t, err) + test.NoError(t, err) // Command test.Equal(t, "http", httpCommand.name) diff --git a/parser.go b/parser.go index 5c1fde9..a1e7724 100644 --- a/parser.go +++ b/parser.go @@ -26,7 +26,6 @@ type parser struct { index int argumentIndex int - errors []error } func newParser(command *Command, tokens []string) *parser {