198 lines
5.5 KiB
Go
198 lines
5.5 KiB
Go
package cliutil
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"io"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// optBool is the minimal stand-in for clicmd's optional flags: a flag.Value
|
|
// that announces it takes no argument.
|
|
type optBool struct{ v bool }
|
|
|
|
func (o *optBool) String() string { return "" }
|
|
func (o *optBool) Set(string) error { o.v = true; return nil }
|
|
func (o *optBool) IsBoolFlag() bool { return true }
|
|
|
|
func TestPermute(t *testing.T) {
|
|
newFS := func() *flag.FlagSet {
|
|
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
|
fs.String("name", "", "")
|
|
fs.Bool("yes", false, "")
|
|
fs.Var(&optBool{}, "spa", "")
|
|
return fs
|
|
}
|
|
|
|
cases := []struct {
|
|
desc string
|
|
in []string
|
|
want []string
|
|
}{
|
|
{
|
|
desc: "flags after the positional, which is what people type",
|
|
in: []string{"demo", "--name", "Demo", "--spa"},
|
|
want: []string{"--name", "Demo", "--spa", "--", "demo"},
|
|
},
|
|
{
|
|
desc: "already in flag package order, unchanged apart from the separator",
|
|
in: []string{"--name", "Demo", "demo"},
|
|
want: []string{"--name", "Demo", "--", "demo"},
|
|
},
|
|
{
|
|
desc: "a bool flag must not swallow the positional that follows it",
|
|
in: []string{"--yes", "demo"},
|
|
want: []string{"--yes", "--", "demo"},
|
|
},
|
|
{
|
|
desc: "single-dash spelling is the same flag",
|
|
in: []string{"demo", "-name", "Demo"},
|
|
want: []string{"-name", "Demo", "--", "demo"},
|
|
},
|
|
{
|
|
desc: "--flag=value carries its own argument",
|
|
in: []string{"demo", "--name=Demo", "other"},
|
|
want: []string{"--name=Demo", "--", "demo", "other"},
|
|
},
|
|
{
|
|
desc: "a value that looks like a flag is still the flag's value",
|
|
in: []string{"demo", "--name", "-weird"},
|
|
want: []string{"--name", "-weird", "--", "demo"},
|
|
},
|
|
{
|
|
desc: "everything after -- stays positional",
|
|
in: []string{"--yes", "--", "--name", "demo"},
|
|
want: []string{"--yes", "--", "--name", "demo"},
|
|
},
|
|
{
|
|
desc: "a lone dash is a positional, not a flag",
|
|
in: []string{"-"},
|
|
want: []string{"--", "-"},
|
|
},
|
|
{
|
|
desc: "an unknown flag is passed through alone so flag.Parse reports it",
|
|
in: []string{"--nope", "demo"},
|
|
want: []string{"--nope", "--", "demo"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
got := permute(newFS(), tc.in)
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("permute(%q)\n got %q\nwant %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPermutedFlagsReachExec is the property the permutation exists for: the
|
|
// command sees the same flags and the same positionals either way round.
|
|
func TestPermutedFlagsReachExec(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"create", "demo", "--name", "Demo", "--spa"},
|
|
{"create", "--name", "Demo", "--spa", "demo"},
|
|
{"create", "--name", "Demo", "demo", "--spa"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var (
|
|
name string
|
|
spa optBool
|
|
rest []string
|
|
)
|
|
root := &Command{
|
|
Name: "test",
|
|
Sub: []*Command{{
|
|
Name: "create",
|
|
Flags: func(fs *flag.FlagSet) {
|
|
fs.StringVar(&name, "name", "", "")
|
|
fs.Var(&spa, "spa", "")
|
|
},
|
|
Exec: func(ctx context.Context, args []string) error {
|
|
rest = args
|
|
return nil
|
|
},
|
|
}},
|
|
}
|
|
if err := Run(context.Background(), root, args, io.Discard, nil); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if name != "Demo" || !spa.v {
|
|
t.Errorf("flags: name=%q spa=%v, want Demo/true", name, spa.v)
|
|
}
|
|
if !reflect.DeepEqual(rest, []string{"demo"}) {
|
|
t.Errorf("positionals: %q, want [demo]", rest)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParentDoesNotPermute guards the other half of the rule: a parent must
|
|
// leave a child's flags alone, or "pages project create --spa" would try to
|
|
// parse --spa against the project command and fail.
|
|
func TestParentDoesNotPermute(t *testing.T) {
|
|
var spa bool
|
|
root := &Command{
|
|
Name: "test",
|
|
Sub: []*Command{{
|
|
Name: "project",
|
|
Sub: []*Command{{
|
|
Name: "create",
|
|
Flags: func(fs *flag.FlagSet) { fs.BoolVar(&spa, "spa", false, "") },
|
|
Exec: func(context.Context, []string) error { return nil },
|
|
}},
|
|
}},
|
|
}
|
|
if err := Run(context.Background(), root, []string{"project", "create", "--spa"}, io.Discard, nil); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if !spa {
|
|
t.Error("--spa did not reach the leaf command")
|
|
}
|
|
}
|
|
|
|
func TestUsageErrors(t *testing.T) {
|
|
root := &Command{
|
|
Name: "test",
|
|
Sub: []*Command{{
|
|
Name: "leaf",
|
|
Exec: func(context.Context, []string) error { return UsageErrorf("expected %d args", 1) },
|
|
}},
|
|
}
|
|
|
|
t.Run("unknown command", func(t *testing.T) {
|
|
var out strings.Builder
|
|
err := Run(context.Background(), root, []string{"nope"}, &out, nil)
|
|
if !errors.Is(err, ErrUsage) {
|
|
t.Fatalf("err = %v, want ErrUsage", err)
|
|
}
|
|
if !strings.Contains(out.String(), `unknown command "nope"`) {
|
|
t.Errorf("output did not name the unknown command:\n%s", out.String())
|
|
}
|
|
})
|
|
|
|
t.Run("a parent with no child named is usage, not a crash", func(t *testing.T) {
|
|
err := Run(context.Background(), root, nil, io.Discard, nil)
|
|
if !errors.Is(err, ErrUsage) {
|
|
t.Fatalf("err = %v, want ErrUsage", err)
|
|
}
|
|
})
|
|
|
|
t.Run("UsageErrorf prints the command's usage and keeps its message", func(t *testing.T) {
|
|
var out strings.Builder
|
|
err := Run(context.Background(), root, []string{"leaf"}, &out, nil)
|
|
if !errors.Is(err, ErrUsage) {
|
|
t.Fatalf("err = %v, want ErrUsage", err)
|
|
}
|
|
if err.Error() != "expected 1 args" {
|
|
t.Errorf("message = %q", err.Error())
|
|
}
|
|
if !strings.Contains(out.String(), "Usage:\n test leaf") {
|
|
t.Errorf("usage text missing or misnamed:\n%s", out.String())
|
|
}
|
|
})
|
|
}
|