Files
2026-08-15 07:13:00 +00:00

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