diff --git a/command.go b/command.go index 97a8182..b18894d 100644 --- a/command.go +++ b/command.go @@ -79,8 +79,8 @@ type Command struct { // MustNew creates a new command using [New], but will panic if it returns an error. // //goland:noinspection GoUnusedExportedFunction -func MustNew(name, shortDescription, longDescription string, action Action, preRunHooks []PreRunHook, postRunHooks []PostRunHook, subCommands ...*Command) *Command { - cmd, err := New(name, shortDescription, longDescription, action, preRunHooks, postRunHooks, subCommands...) +func MustNew(name, shortDescription, longDescription string, action Action, hooks []any, subCommands ...*Command) *Command { + cmd, err := New(name, shortDescription, longDescription, action, hooks, subCommands...) if err != nil { panic(err) } @@ -89,13 +89,32 @@ func MustNew(name, shortDescription, longDescription string, action Action, preR // New creates a new command with the given name, short & long descriptions, and the given executor. The executor object // is also scanned for configuration structs via reflection. -func New(name, shortDescription, longDescription string, action Action, preRunHooks []PreRunHook, postRunHooks []PostRunHook, subCommands ...*Command) (*Command, error) { +func New(name, shortDescription, longDescription string, action Action, hooks []any, subCommands ...*Command) (*Command, error) { if name == "" { return nil, fmt.Errorf("%w: empty name", ErrInvalidCommand) } else if shortDescription == "" { return nil, fmt.Errorf("%w: empty short description", ErrInvalidCommand) } + // Translate the any-based hooks list into pre-run and post-run hooks + // Fail on any hook that doesn't implement at least one of them + var preRunHooks []PreRunHook + var postRunHooks []PostRunHook + for i, hook := range hooks { + var pre, post bool + if preRunHook, ok := hook.(PreRunHook); ok { + preRunHooks = append(preRunHooks, preRunHook) + pre = true + } + if postRunHook, ok := hook.(PostRunHook); ok { + postRunHooks = append(postRunHooks, postRunHook) + post = true + } + if !pre && !post { + return nil, fmt.Errorf("%w: hook %d (%T) is neither a PreRunHook nor a PostRunHook", ErrInvalidCommand, i, hook) + } + } + // Create the command instance cmd := &Command{ name: name, diff --git a/command_test.go b/command_test.go index 9a468ef..b358f6d 100644 --- a/command_test.go +++ b/command_test.go @@ -25,19 +25,19 @@ func TestNew(t *testing.T) { testCases := map[string]testCase{ "empty name": { commandFactory: func(t T, tc *testCase) (*Command, error) { - return New("", "short desc", "long desc", nil, nil, nil) + return New("", "short desc", "long desc", nil, nil) }, expectedError: `^invalid command: empty name$`, }, "empty short description": { commandFactory: func(t T, tc *testCase) (*Command, error) { - return New("cmd", "", "long desc", nil, nil, nil) + return New("cmd", "", "long desc", nil, nil) }, expectedError: `^invalid command: empty short description$`, }, "no flags": { commandFactory: func(t T, tc *testCase) (*Command, error) { - return New("cmd", "desc", "long desc", nil, nil, nil) + return New("cmd", "desc", "long desc", nil, nil) }, expectedName: "cmd", expectedShortDescription: "desc", @@ -54,7 +54,6 @@ func TestNew(t *testing.T) { MyFlag string `flag:"true"` }{}, nil, - nil, ) }, expectedFlagSet: &flagSet{ @@ -97,13 +96,13 @@ func TestNew(t *testing.T) { func TestAddSubCommand(t *testing.T) { t.Parallel() - root, err := New("root", "desc", "description", nil, nil, nil) + root, err := New("root", "desc", "description", nil, nil) With(t).Verify(err).Will(BeNil()).OrFail() - sub1, err := New("sub1", "sub1 desc", "sub1 description", nil, nil, nil) + sub1, err := New("sub1", "sub1 desc", "sub1 description", nil, nil) With(t).Verify(err).Will(BeNil()).OrFail() - sub2, err := New("sub2", "sub2 desc", "sub2 description", nil, nil, nil) + sub2, err := New("sub2", "sub2 desc", "sub2 description", nil, nil) With(t).Verify(err).Will(BeNil()).OrFail() With(t).Verify(root.AddSubCommand(sub1)).Will(BeNil()).OrFail() @@ -124,10 +123,10 @@ func Test_inferCommandAndArgs(t *testing.T) { testCases := map[string]testCase{ "No arguments": { root: MustNew( - "root", "desc", "description", nil, nil, nil, - MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, nil, - MustNew("sub2", "sub2 desc", "sub2 description", nil, nil, nil, - MustNew("sub3", "sub3 desc", "sub3 description", nil, nil, nil), + "root", "desc", "description", nil, nil, + MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, + MustNew("sub2", "sub2 desc", "sub2 description", nil, nil, + MustNew("sub3", "sub3 desc", "sub3 description", nil, nil), ), ), ), @@ -138,9 +137,9 @@ func Test_inferCommandAndArgs(t *testing.T) { }, "Flags for root command": { root: MustNew( - "root", "desc", "description", nil, nil, nil, - MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, nil, - MustNew("sub2", "sub2 desc", "sub2 description", nil, nil, nil), + "root", "desc", "description", nil, nil, + MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, + MustNew("sub2", "sub2 desc", "sub2 description", nil, nil), ), ), args: strings.Split("-f1 -f2", " "), @@ -150,9 +149,9 @@ func Test_inferCommandAndArgs(t *testing.T) { }, "Flags and positionals for root command": { root: MustNew( - "root", "desc", "description", nil, nil, nil, - MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, nil, - MustNew("sub2", "sub2 desc", "sub2 description", nil, nil, nil), + "root", "desc", "description", nil, nil, + MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, + MustNew("sub2", "sub2 desc", "sub2 description", nil, nil), ), ), args: strings.Split("-f1 a -f2 b", " "), @@ -162,9 +161,9 @@ func Test_inferCommandAndArgs(t *testing.T) { }, "Flags and positionals for sub1 command": { root: MustNew( - "root", "desc", "description", nil, nil, nil, - MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, nil, - MustNew("sub2", "sub2 desc", "sub2 description", nil, nil, nil), + "root", "desc", "description", nil, nil, + MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, + MustNew("sub2", "sub2 desc", "sub2 description", nil, nil), ), ), args: strings.Split("-f1 sub1 -f2 a b", " "), @@ -174,9 +173,9 @@ func Test_inferCommandAndArgs(t *testing.T) { }, "Flags and positionals for sub2 command": { root: MustNew( - "root", "desc", "description", nil, nil, nil, - MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, nil, - MustNew("sub2", "sub2 desc", "sub2 description", nil, nil, nil), + "root", "desc", "description", nil, nil, + MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, + MustNew("sub2", "sub2 desc", "sub2 description", nil, nil), ), ), args: strings.Split("-f1 sub1 -f2 a b sub2 c", " "), @@ -201,10 +200,10 @@ func Test_getFullName(t *testing.T) { cmd *Command expectedFullName string } - sub3 := MustNew("sub3", "sub3 desc", "sub3 description", nil, nil, nil) - sub2 := MustNew("sub2", "sub2 desc", "sub2 description", nil, nil, nil, sub3) - sub1 := MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, nil, sub2) - root := MustNew("root", "desc", "description", nil, nil, nil, sub1) + sub3 := MustNew("sub3", "sub3 desc", "sub3 description", nil, nil) + sub2 := MustNew("sub2", "sub2 desc", "sub2 description", nil, nil, sub3) + sub1 := MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, sub2) + root := MustNew("root", "desc", "description", nil, nil, sub1) testCases := map[string]testCase{ "root": { cmd: root, @@ -236,10 +235,10 @@ func Test_getChain(t *testing.T) { cmd *Command expectedChain []string } - sub3 := MustNew("sub3", "sub3 desc", "sub3 description", nil, nil, nil) - sub2 := MustNew("sub2", "sub2 desc", "sub2 description", nil, nil, nil, sub3) - sub1 := MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, nil, sub2) - root := MustNew("root", "desc", "description", nil, nil, nil, sub1) + sub3 := MustNew("sub3", "sub3 desc", "sub3 description", nil, nil) + sub2 := MustNew("sub2", "sub2 desc", "sub2 description", nil, nil, sub3) + sub1 := MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, sub2) + root := MustNew("root", "desc", "description", nil, nil, sub1) testCases := map[string]testCase{ "root": { cmd: root, @@ -282,7 +281,7 @@ func TestPrintHelp(t *testing.T) { "no flags & no positionals": { commandFactory: func(*testCase) *Command { ligen := loremipsum.NewWithSeed(4321) - return MustNew("cmd", ligen.Sentence(), ligen.Sentences(2), nil, nil, nil) + return MustNew("cmd", ligen.Sentence(), ligen.Sentences(2), nil, nil) }, expectedHelpUsageOutput: ` Usage: cmd [--help] @@ -326,7 +325,6 @@ Flags: Args []string `args:"true"` }{}, nil, - nil, ) }, expectedHelpUsageOutput: ` @@ -377,7 +375,6 @@ Flags: Args []string `args:"true"` }{}, nil, - nil, MustNew( "child1", ligen.Sentence(), @@ -388,7 +385,6 @@ Flags: Args []string `args:"true"` }{}, nil, - nil, ), ) }, diff --git a/execute_test.go b/execute_test.go index 92e9a20..3a26e04 100644 --- a/execute_test.go +++ b/execute_test.go @@ -69,8 +69,8 @@ func TestExecute(t *testing.T) { t.Run("command must be root", func(t *testing.T) { ctx := context.Background() - child := MustNew("child", "desc", "long desc", nil, nil, nil) - _ = MustNew("root", "desc", "long desc", nil, nil, nil, child) + child := MustNew("child", "desc", "long desc", nil, nil) + _ = MustNew("root", "desc", "long desc", nil, nil, child) b := &bytes.Buffer{} With(t).Verify(Execute(ctx, b, child, nil, nil)).Will(EqualTo(ExitCodeError)).OrFail() With(t).Verify(b).Will(Say(`^unsupported operation: command must be the root command$`)).OrFail() @@ -78,14 +78,14 @@ func TestExecute(t *testing.T) { t.Run("applies configuration", func(t *testing.T) { ctx := context.Background() - cmd := MustNew("cmd", "desc", "long desc", &ActionWithConfig{}, nil, nil) + cmd := MustNew("cmd", "desc", "long desc", &ActionWithConfig{}, nil) With(t).Verify(Execute(ctx, os.Stderr, cmd, []string{"--my-flag=V1"}, nil)).Will(EqualTo(ExitCodeSuccess)).OrFail() With(t).Verify(cmd.action.(*ActionWithConfig).MyFlag).Will(EqualTo("V1")).OrFail() }) t.Run("prints usage on CLI parse errors", func(t *testing.T) { ctx := context.Background() - cmd := MustNew("cmd", "desc", "long desc", &ActionWithConfig{}, nil, nil) + cmd := MustNew("cmd", "desc", "long desc", &ActionWithConfig{}, nil) b := &bytes.Buffer{} With(t).Verify(Execute(ctx, b, cmd, []string{"--bad-flag=V1"}, nil)).Will(EqualTo(ExitCodeMisconfiguration)).OrFail() With(t).Verify(cmd.action.(*ActionWithConfig).MyFlag).Will(BeEmpty()).OrFail() @@ -94,7 +94,7 @@ func TestExecute(t *testing.T) { t.Run("prints help on --help flag", func(t *testing.T) { ctx := context.Background() - cmd := MustNew("cmd", "desc", "long desc", &ActionWithConfig{}, nil, nil) + cmd := MustNew("cmd", "desc", "long desc", &ActionWithConfig{}, nil) b := &bytes.Buffer{} With(t).Verify(Execute(ctx, b, cmd, []string{"--help"}, nil)).Will(EqualTo(ExitCodeSuccess)).OrFail() With(t).Verify(b.String()).Will(EqualTo(` @@ -115,9 +115,9 @@ Flags: t.Run("preRun called for command chain", func(t *testing.T) { ctx := context.Background() - sub2 := MustNew("sub2", "desc", "long desc", &ActionWithConfig{}, []PreRunHook{&PreRunHookWithConfig{}}, nil) - sub1 := MustNew("sub1", "desc", "long desc", nil, []PreRunHook{&PreRunHookWithConfig{}}, nil, sub2) - root := MustNew("cmd", "desc", "long desc", nil, []PreRunHook{&PreRunHookWithConfig{}}, nil, sub1) + sub2 := MustNew("sub2", "desc", "long desc", &ActionWithConfig{}, []any{&PreRunHookWithConfig{}}) + sub1 := MustNew("sub1", "desc", "long desc", nil, []any{&PreRunHookWithConfig{}}, sub2) + root := MustNew("cmd", "desc", "long desc", nil, []any{&PreRunHookWithConfig{}}, sub1) With(t).Verify(Execute(ctx, os.Stderr, root, []string{"sub1", "sub2"}, nil)).Will(EqualTo(ExitCodeSuccess)).OrFail() rootPreRunHook := root.preRunHooks[0].(*PreRunHookWithConfig) @@ -140,9 +140,9 @@ Flags: passThroughPreHook := func() PreRunHook { return &PreRunHookWithConfig{} } ctx := context.Background() - sub2 := MustNew("sub2", "desc", "long desc", &ActionWithConfig{}, []PreRunHook{passThroughPreHook()}, nil) - sub1 := MustNew("sub1", "desc", "long desc", nil, []PreRunHook{passThroughPreHook(), failingPreHook}, nil, sub2) - root := MustNew("cmd", "desc", "long desc", nil, []PreRunHook{passThroughPreHook()}, nil, sub1) + sub2 := MustNew("sub2", "desc", "long desc", &ActionWithConfig{}, []any{passThroughPreHook()}) + sub1 := MustNew("sub1", "desc", "long desc", nil, []any{passThroughPreHook(), failingPreHook}, sub2) + root := MustNew("cmd", "desc", "long desc", nil, []any{passThroughPreHook()}, sub1) rootPreRunHook := root.preRunHooks[0].(*PreRunHookWithConfig) sub1PreRunHook := sub1.preRunHooks[0].(*PreRunHookWithConfig) @@ -159,9 +159,9 @@ Flags: t.Run("postRun called for command chain", func(t *testing.T) { ctx := context.Background() - sub2 := MustNew("sub2", "desc", "long desc", &ActionWithConfig{}, nil, []PostRunHook{&PostRunHookWithConfig{}}) - sub1 := MustNew("sub1", "desc", "long desc", nil, nil, []PostRunHook{&PostRunHookWithConfig{}}, sub2) - root := MustNew("cmd", "desc", "long desc", nil, nil, []PostRunHook{&PostRunHookWithConfig{}}, sub1) + sub2 := MustNew("sub2", "desc", "long desc", &ActionWithConfig{}, []any{&PostRunHookWithConfig{}}) + sub1 := MustNew("sub1", "desc", "long desc", nil, []any{&PostRunHookWithConfig{}}, sub2) + root := MustNew("cmd", "desc", "long desc", nil, []any{&PostRunHookWithConfig{}}, sub1) exitCode := Execute(ctx, os.Stderr, root, []string{"sub1", "sub2"}, nil) With(t).Verify(exitCode).Will(EqualTo(ExitCodeSuccess)).OrFail() @@ -194,9 +194,9 @@ Flags: failingAction := &ActionWithConfig{TrackingAction: TrackingAction{errorToReturnOnCall: fmt.Errorf("failing action")}} ctx := context.Background() - sub2 := MustNew("sub2", "desc", "long desc", failingAction, nil, []PostRunHook{failingPostHook()}) - sub1 := MustNew("sub1", "desc", "long desc", nil, nil, []PostRunHook{passThroughPostHook()}, sub2) - root := MustNew("cmd", "desc", "long desc", nil, nil, []PostRunHook{passThroughPostHook()}, sub1) + sub2 := MustNew("sub2", "desc", "long desc", failingAction, []any{failingPostHook()}) + sub1 := MustNew("sub1", "desc", "long desc", nil, []any{passThroughPostHook()}, sub2) + root := MustNew("cmd", "desc", "long desc", nil, []any{passThroughPostHook()}, sub1) exitCode := Execute(ctx, os.Stderr, root, []string{"sub1", "sub2"}, nil) With(t).Verify(exitCode).Will(EqualTo(ExitCodeError)).OrFail() @@ -228,7 +228,7 @@ Flags: } action := &ActionWithRequiredFlag{} ctx := context.Background() - root := MustNew("cmd", "desc", "long desc", action, nil, nil) + root := MustNew("cmd", "desc", "long desc", action, nil) b := &bytes.Buffer{} With(t).Verify(Execute(ctx, b, root, nil, nil)).Will(EqualTo(ExitCodeMisconfiguration)).OrFail() @@ -245,7 +245,7 @@ Flags: MyFlag: "abc", } ctx := context.Background() - root := MustNew("cmd", "desc", "long desc", action, nil, nil) + root := MustNew("cmd", "desc", "long desc", action, nil) b := &bytes.Buffer{} With(t).Verify(Execute(ctx, b, root, nil, nil)).Will(EqualTo(ExitCodeSuccess)).OrFail() diff --git a/flag_set_test.go b/flag_set_test.go index 63153c5..23a1be7 100644 --- a/flag_set_test.go +++ b/flag_set_test.go @@ -978,6 +978,18 @@ func TestFlagSetApply(t *testing.T) { args: []string{"--my-field1=VVV1"}, expectedError: `^required flag is missing: --my-field2$`, }, + "optional string field is not required": { + config: &struct { + F1 string `desc:"Some desc."` + F2 string `required:"true" desc:"Some desc."` + }{F2: "v2"}, + envVars: map[string]string{}, + args: []string{}, + expectedConfig: &struct { + F1 string `desc:"Some desc."` + F2 string `required:"true" desc:"Some desc."` + }{F1: "", F2: "v2"}, + }, "bool flag default value is considered": { config: &struct { F1 bool `name:"my-field1" required:"true"`