Skip to content

Commit

Permalink
feat(core): positional argument
Browse files Browse the repository at this point in the history
  • Loading branch information
kindermoumoute committed Mar 10, 2020
1 parent 8d9afd0 commit 21dd700
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 1 deletion.
16 changes: 16 additions & 0 deletions internal/core/arg_specs.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ import (

type ArgSpecs []*ArgSpec

func (s ArgSpecs) GetPositionalArg() *ArgSpec {
for _, spec := range s {
if spec.Positional {
return spec
}
}
return nil
}

func (s ArgSpecs) GetByName(name string) *ArgSpec {
for _, spec := range s {
if spec.Name == name {
Expand Down Expand Up @@ -62,6 +71,13 @@ type ArgSpec struct {

// ValidateFunc validates an argument.
ValidateFunc ArgSpecValidateFunc

// Positional defines whether the argument is a positional parameter.
Positional bool
}

func (a *ArgSpec) Prefix() string {
return a.Name + "="
}

type DefaultFunc func() (value string, doc string)
Expand Down
64 changes: 64 additions & 0 deletions internal/core/cobra_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ func cobraRun(ctx context.Context, cmd *Command) func(*cobra.Command, []string)
// unmarshalled arguments will be store in this interface
cmdArgs := reflect.New(cmd.ArgsType).Interface()

// Handle positional argument by catching first argument `<value>` and rewrite it to `<arg-name>=<value>`
if err = handlePositionalArg(cmd, rawArgs); err != nil {
return err
}

// Apply default values on missing args.
rawArgs = ApplyDefaultValues(cmd.ArgSpecs, rawArgs)

Expand Down Expand Up @@ -78,6 +83,65 @@ func cobraRun(ctx context.Context, cmd *Command) func(*cobra.Command, []string)
}
}

// handlePositionalArg will catch positional argument if command has one.
// When a positional argument is found it will mutate its value in rawArgs to match the argument unmarshaller format.
// E.g.: '[value b=true c=1]' will be mutated to '[a=value b=true c=1]'.
// It returns errors when:
// - no positional argument is found.
// - a positional argument exists, but is not positional.
// - an argument duplicates a positional argument.
func handlePositionalArg(cmd *Command, rawArgs []string) error {
positionalArg := cmd.ArgSpecs.GetPositionalArg()

// Command does not have a positional argument.
if positionalArg == nil {
return nil
}

// Positional argument is found condition.
positionalArgumentFound := len(rawArgs) > 0 && !strings.Contains(rawArgs[0], "=")

// Argument exists but is not positional.
for i, arg := range rawArgs {
if strings.HasPrefix(arg, positionalArg.Prefix()) {
return &CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: positionalArgHint(cmd, strings.TrimLeft(arg, positionalArg.Prefix()), append(rawArgs[:i], rawArgs[i+1:]...), positionalArgumentFound),
}
}
}

// If positional argument is found, prefix it with `arg-name=`.
if positionalArgumentFound {
rawArgs[0] = positionalArg.Prefix() + rawArgs[0]
return nil
}

// No positional argument found.
return &CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: positionalArgHint(cmd, "<"+positionalArg.Name+">", rawArgs, false),
}
}

// positionalArgHint helps formatting the positional argument error.
func positionalArgHint(cmd *Command, hintValue string, otherArgs []string, positionalArgumentFound bool) string {
suggestedArgs := []string{}

// If no positional argument exists, suggest one.
if !positionalArgumentFound {
suggestedArgs = append(suggestedArgs, hintValue)
}

// Suggest to use the other arguments.
for _, arg := range otherArgs {
suggestedArgs = append(suggestedArgs, arg)
}

suggestedCommand := append([]string{"scw", cmd.getPath()}, suggestedArgs...)
return "Try running '" + strings.Join(suggestedCommand, " ") + "'."
}

func handleUnmarshalErrors(cmd *Command, unmarshalErr *args.UnmarshalArgError) error {
wrappedErr := errors.Unwrap(unmarshalErr)

Expand Down
95 changes: 94 additions & 1 deletion internal/core/cobra_utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import (
)

type testType struct {
Name string
NameID string
Tag string
}

func testGetCommands() *Commands {
Expand All @@ -25,6 +26,22 @@ func testGetCommands() *Commands {
return "", nil
},
},
&Command{
Namespace: "test-positional",
ArgSpecs: ArgSpecs{
{
Name: "name-id",
Positional: true,
},
{
Name: "tag",
},
},
ArgsType: reflect.TypeOf(testType{}),
Run: func(ctx context.Context, argsI interface{}) (i interface{}, e error) {
return "", nil
},
},
)
}

Expand Down Expand Up @@ -53,3 +70,79 @@ func Test_handleUnmarshalErrors(t *testing.T) {
),
}))
}

func Test_PositionalArg(t *testing.T) {
t.Run("Error", func(t *testing.T) {
t.Run("Missing1", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional",
Check: TestCheckCombine(
TestCheckExitCode(1),
TestCheckError(&CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: "Try running 'scw test-positional <name-id>'.",
}),
),
}))

t.Run("Missing2", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional tag=world",
Check: TestCheckCombine(
TestCheckExitCode(1),
TestCheckError(&CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: "Try running 'scw test-positional <name-id> tag=world'.",
}),
),
}))

t.Run("Invalid1", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional name-id=plop tag=world",
Check: TestCheckCombine(
TestCheckExitCode(1),
TestCheckError(&CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: "Try running 'scw test-positional plop tag=world'.",
}),
),
}))

t.Run("Invalid2", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional tag=world name-id=plop",
Check: TestCheckCombine(
TestCheckExitCode(1),
TestCheckError(&CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: fmt.Sprintf("Try running 'scw test-positional plop tag=world'."),
}),
),
}))

t.Run("Invalid3", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional plop name-id=plop",
Check: TestCheckCombine(
TestCheckExitCode(1),
TestCheckError(&CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: fmt.Sprintf("Try running 'scw test-positional plop'."),
}),
),
}))
})

t.Run("simple", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional plop",
Check: TestCheckExitCode(0),
}))

t.Run("full command", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional plop tag=world",
Check: TestCheckExitCode(0),
}))
}

0 comments on commit 21dd700

Please sign in to comment.