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()) } }) }