From 2cac63508e86c467fc3bcb8f7d1cb2170a84e044 Mon Sep 17 00:00:00 2001 From: Oleg Kovalov Date: Wed, 3 Nov 2021 17:13:06 +0100 Subject: [PATCH] Add nested commands (#2) --- acmd.go | 54 ++++++++++++++++++++++++++--------- acmd_test.go | 75 ++++++++++++++++++++++++++++++++++++++++++++++--- example_test.go | 37 ++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 18 deletions(-) diff --git a/acmd.go b/acmd.go index 024cf67..a12fca4 100644 --- a/acmd.go +++ b/acmd.go @@ -44,7 +44,7 @@ type Command struct { Do func(ctx context.Context, args []string) error // subcommands of the command. - subcommands []Command + Subcommands []Command } // Config for the runner. @@ -116,8 +116,7 @@ func (r *Runner) init() error { cmds := r.cmds r.rootCmd = Command{ Name: "root", - Do: nopFunc, - subcommands: cmds, + Subcommands: cmds, } if err := validateCommand(r.rootCmd); err != nil { return err @@ -146,17 +145,21 @@ func (r *Runner) init() error { return cmds[i].Name < cmds[j].Name }) - r.rootCmd.subcommands = cmds + r.rootCmd.Subcommands = cmds + r.rootCmd.Do = rootDo(r.cfg, cmds) return nil } func validateCommand(cmd Command) error { - cmds := cmd.subcommands + cmds := cmd.Subcommands switch { case cmd.Do == nil && len(cmds) == 0: - return fmt.Errorf("command %q function cannot be nil", cmd.Name) + return fmt.Errorf("command %q function cannot be nil or must have subcommands", cmd.Name) + + case cmd.Do != nil && len(cmds) != 0: + return fmt.Errorf("command %q function cannot be set and have subcommands", cmd.Name) case cmd.Name == "help" || cmd.Name == "version": return fmt.Errorf("command %q is reserved", cmd.Name) @@ -209,23 +212,46 @@ func (r *Runner) Run() error { if r.errInit != nil { return fmt.Errorf("acmd: cannot init runner: %w", r.errInit) } - if err := run(r.ctx, r.cfg, r.rootCmd.subcommands, r.args); err != nil { + if err := r.rootCmd.Do(r.ctx, r.args); err != nil { return fmt.Errorf("acmd: cannot run command: %w", err) } return nil } -func run(ctx context.Context, cfg Config, cmds []Command, args []string) error { - selected, params := args[0], args[1:] - - for _, c := range cmds { - if selected == c.Name || selected == c.Alias { - return c.Do(ctx, params) +func rootDo(cfg Config, cmds []Command) func(ctx context.Context, args []string) error { + return func(ctx context.Context, args []string) error { + cmds, args := cmds, args + for { + selected, params := args[0], args[1:] + + var found bool + for _, c := range cmds { + if selected != c.Name && selected != c.Alias { + continue + } + + // go deeper into subcommands + if c.Do == nil { + if len(params) == 0 { + return errors.New("no args for subcmd provided") + } + cmds, args = c.Subcommands, params + found = true + break + } + return c.Do(ctx, params) + } + + if !found { + return errNotFoundAndSuggest(cfg.Output, selected, cmds) + } } } +} +func errNotFoundAndSuggest(w io.Writer, selected string, cmds []Command) error { if suggestion := suggestCommand(selected, cmds); suggestion != "" { - fmt.Fprintf(cfg.Output, "%q is not a subcommand, did you mean %q?\n", selected, suggestion) + fmt.Fprintf(w, "%q is not a subcommand, did you mean %q?\n", selected, suggestion) } return fmt.Errorf("no such command %q", selected) } diff --git a/acmd_test.go b/acmd_test.go index b89cc28..4d19944 100644 --- a/acmd_test.go +++ b/acmd_test.go @@ -2,12 +2,67 @@ package acmd import ( "bytes" + "context" + "fmt" "os" "sort" "strings" "testing" + "time" ) +func TestRunner(t *testing.T) { + buf := &bytes.Buffer{} + + cmds := []Command{ + { + Name: "test", + Description: "some test command", + Subcommands: []Command{ + { + Name: "foo", + Subcommands: []Command{ + { + Name: "for", Do: func(ctx context.Context, args []string) error { + fmt.Fprint(buf, "for") + return nil + }, + }, + }, + }, + { + Name: "bar", + Do: func(ctx context.Context, args []string) error { + fmt.Fprint(buf, "bar") + return nil + }, + }, + }, + }, + { + Name: "status", + Description: "status command gives status of the state", + Do: func(ctx context.Context, args []string) error { + return nil + }, + }, + } + r := RunnerOf(cmds, Config{ + Args: []string{"test", "foo", "for"}, + AppName: "acmd_test_app", + AppDescription: "acmd_test_app is a test application.", + Version: time.Now().String(), + Output: buf, + }) + + if err := r.Run(); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != "for" { + t.Fatalf("want %q got %q", "for", got) + } +} + func TestRunnerMustSetDefaults(t *testing.T) { cmds := []Command{{Name: "foo", Do: nopFunc}} r := RunnerOf(cmds, Config{}) @@ -34,7 +89,7 @@ func TestRunnerMustSetDefaults(t *testing.T) { } gotCmds := map[string]struct{}{} - for _, c := range r.rootCmd.subcommands { + for _, c := range r.rootCmd.Subcommands { gotCmds[c.Name] = struct{}{} } if _, ok := gotCmds["help"]; !ok { @@ -95,6 +150,18 @@ func TestRunnerInit(t *testing.T) { cmds: []Command{{Name: "foo%", Do: nil}}, wantErrStr: `command "foo%" function cannot be nil`, }, + { + cmds: []Command{{Name: "foo", Do: nil}}, + wantErrStr: `command "foo" function cannot be nil or must have subcommands`, + }, + { + cmds: []Command{{ + Name: "foobar", + Do: nopFunc, + Subcommands: []Command{{Name: "nested"}}, + }}, + wantErrStr: `command "foobar" function cannot be set and have subcommands`, + }, { cmds: []Command{{Name: "foo", Do: nopFunc}}, cfg: Config{ @@ -183,12 +250,12 @@ func TestRunner_suggestCommand(t *testing.T) { Args: tc.args, Output: buf, }) - if err := r.Run(); err == nil { - t.Fatal() + if err := r.Run(); err != nil && !strings.Contains(err.Error(), "no such command") { + t.Fatal(err) } if got := buf.String(); got != tc.want { - t.Logf("want %q got %q", tc.want, got) + t.Fatalf("want %q got %q", tc.want, got) } } } diff --git a/example_test.go b/example_test.go index c3b2115..c94f621 100644 --- a/example_test.go +++ b/example_test.go @@ -194,3 +194,40 @@ func ExampleAutosuggestion() { // Output: "baz" is not a subcommand, did you mean "bar"? } + +func ExampleNestedCommands() { + testOut := os.Stdout + testArgs := []string{"foo", "qux"} + + cmds := []acmd.Command{ + { + Name: "foo", + Subcommands: []acmd.Command{ + {Name: "bar", Do: nopFunc}, + {Name: "baz", Do: nopFunc}, + { + Name: "qux", + Do: func(ctx context.Context, args []string) error { + fmt.Fprint(testOut, "qux") + return nil + }, + }, + }, + }, + {Name: "boom", Do: nopFunc}, + } + + r := acmd.RunnerOf(cmds, acmd.Config{ + AppName: "acmd-example", + AppDescription: "Example of acmd package", + Version: "the best v0.x.y", + Output: testOut, + Args: testArgs, + }) + + if err := r.Run(); err != nil { + panic(err) + } + + // Output: qux +}