test command validation

This commit is contained in:
Arpad Ryszka 2025-09-06 02:46:28 +02:00
parent 2d93474cb1
commit e348cce3d2
14 changed files with 883 additions and 220 deletions

View File

@ -1,4 +1,4 @@
SOURCES = $(shell find . -name "*.go" | grep -v iniparser.gen.go | grep -v docreflect.gen.go)
SOURCES = $(shell find . -name "*.go" | grep -v iniparser.gen.go | grep -v docreflect.gen.go | grep -v docreflect_test.go)
default: build

View File

@ -9,7 +9,7 @@ import (
"strings"
)
var commandNameExpression = regexp.MustCompile("^[a-zA-Z_][a-zA-Z_0-9]*$")
var commandNameExpression = regexp.MustCompile("^[a-zA-Z_][a-zA-Z_0-9-]*$")
func wrap(impl any) Cmd {
cmd, ok := impl.(Cmd)
@ -120,10 +120,6 @@ func validateImpl(cmd Cmd, conf Config) error {
}
func validateCommandTree(cmd Cmd, conf Config) error {
if cmd.helpFor != nil {
return nil
}
if cmd.version != "" {
return nil
}
@ -171,7 +167,7 @@ func validateCommandTree(cmd Cmd, conf Config) error {
}
if err := validateCommandTree(s, conf); err != nil {
return fmt.Errorf("%s: %w", s.name, err)
return fmt.Errorf("%s: %w", cmd.name, err)
}
}
@ -227,10 +223,6 @@ func validateShortFormsTree(cmd Cmd) (map[string]string, map[string]string, erro
}
}
if len(cmd.shortForms)%2 != 0 {
return nil, nil, fmt.Errorf("unassigned short form: %s", cmd.shortForms[len(cmd.shortForms)-1])
}
mf := mapFields(cmd.impl)
_, helpDefined := mf["help"]
for i := 0; i < len(cmd.shortForms); i += 2 {
@ -304,26 +296,6 @@ func validateCommand(cmd Cmd, conf Config) error {
return nil
}
func insertHelpOption(names []string) []string {
for _, n := range names {
if n == "help" {
return names
}
}
return append(names, "help")
}
func insertHelpShortForm(shortForms []string) []string {
for _, sf := range shortForms {
if sf == "h" {
return shortForms
}
}
return append(shortForms, "h")
}
func boolOptions(cmd Cmd) []string {
f := fields(cmd.impl)
b := boolFields(f)
@ -333,7 +305,18 @@ func boolOptions(cmd Cmd) []string {
n = append(n, fi.Name())
}
n = insertHelpOption(n)
var hasHelp bool
for _, fi := range f {
if fi.Name() == "help" {
hasHelp = true
break
}
}
if !hasHelp {
n = append(n, "help")
}
sfm := make(map[string][]string)
for i := 0; i < len(cmd.shortForms); i += 2 {
s, l := cmd.shortForms[i], cmd.shortForms[i+1]
@ -342,11 +325,20 @@ func boolOptions(cmd Cmd) []string {
var sf []string
for _, ni := range n {
if sn, ok := sfm[ni]; ok {
sf = append(sf, sn...)
sf = append(sf, sfm[ni]...)
}
var hasHelpShortForm bool
for i := 0; i < len(cmd.shortForms); i += 2 {
if cmd.shortForms[i] == "h" {
hasHelpShortForm = true
break
}
}
sf = insertHelpShortForm(sf)
if !hasHelp && !hasHelpShortForm {
sf = append(sf, "h")
}
return append(n, sf...)
}

347
command_test.go Normal file
View File

@ -0,0 +1,347 @@
package wand
import (
"fmt"
"io"
"testing"
)
func TestCommand(t *testing.T) {
t.Run("fields", func(t *testing.T) {
type s0 struct {
FooBar int
Foo struct{ Bar bool }
}
type s1 struct {
Config string
}
f0 := func(a s0) int { return a.FooBar }
t.Run(
"incompatible field types",
testExec(testCase{impl: f0, command: "foo"}, "duplicate fields with different types", ""),
)
f1 := func(a s1) string { return a.Config }
t.Run(
"config option shadowed",
testExec(
testCase{impl: f1, mergeConfTyped: []Config{ConfigFromOption()}, command: "foo"},
"option reserved for config",
"",
),
)
})
t.Run("positional", func(t *testing.T) {
f := func(a, b, c int) int { return a + b + c }
t.Run("ok", testExec(testCase{impl: Args(Command("foo", f), 3, 3), command: "foo 1 2 3"}, "", "6"))
t.Run(
"min less than fixed",
testExec(
testCase{impl: Args(Command("foo", f), 2, 3), command: "foo 1 2 3"},
"minimum positional",
"",
),
)
t.Run(
"min more than fixed",
testExec(
testCase{impl: Args(Command("foo", f), 4, 4), command: "foo 1 2 3"},
"minimum positional",
"",
),
)
t.Run(
"max less than fixed",
testExec(
testCase{impl: Args(Command("foo", f), 0, 2), command: "foo 1 2 3"},
"maximum positional",
"",
),
)
fv := func(a, b int, c ...int) int { return a + b + len(c) }
t.Run("ok variadic", testExec(testCase{impl: Args(Command("foo", fv), 3, 5), command: "foo 1 2 3 4 5"}, "", "6"))
t.Run(
"min less than fixed variadic",
testExec(
testCase{impl: Args(Command("foo", fv), 1, 5), command: "foo 1 2 3"},
"minimum positional",
"",
),
)
t.Run(
"max less than fixed variadic",
testExec(
testCase{impl: Args(Command("foo", fv), 0, 1), command: "foo 1 2 3"},
"maximum positional",
"",
),
)
t.Run(
"min more than max variadic",
testExec(
testCase{impl: Args(Command("foo", fv), 4, 3), command: "foo 1 2 3 4 5"},
"minimum positional",
"",
),
)
fvio := func(a int, in io.Reader, out io.Writer, b int, c ...int) int { return a + b + len(c) }
t.Run("ok io", testExec(testCase{impl: Args(Command("foo", fvio), 3, 5), command: "foo 1 2 3 4 5"}, "", "6"))
t.Run(
"min less than fixed io",
testExec(
testCase{impl: Args(Command("foo", fvio), 1, 5), command: "foo 1 2 3"},
"minimum positional",
"",
),
)
t.Run(
"max less than fixed io",
testExec(
testCase{impl: Args(Command("foo", fvio), 0, 1), command: "foo 1 2 3"},
"maximum positional",
"",
),
)
t.Run(
"min more than max io",
testExec(
testCase{impl: Args(Command("foo", fvio), 4, 3), command: "foo 1 2 3 4 5"},
"minimum positional",
"",
),
)
fio := func(a, b io.Reader) {}
t.Run(
"max one io of a kind",
testExec(testCase{impl: fio, command: "foo"}, "only zero or one reader", ""),
)
})
t.Run("impl", func(t *testing.T) {
t.Run(
"must be func",
testExec(
testCase{impl: []func(){func() {}}, command: "foo"},
"implementation must be a function",
"",
),
)
t.Run(
"paramters must be bindable",
testExec(testCase{impl: func(chan int) {}, command: "foo"}, "unsupported parameter type", ""),
)
})
t.Run("command tree", func(t *testing.T) {
f := func(a, b int) int { return a + b }
cmd := Command("foo", f)
cmd = Version(cmd, "v42")
t.Run("version pass", testExec(testCase{impl: cmd, command: "foo 1 2"}, "", "3"))
cmd = Command("foo", nil)
t.Run(
"no impl and not group",
testExec(testCase{impl: cmd, command: "foo"}, "command does not have an implementation", ""),
)
cmd = Group("foo")
t.Run(
"no impl and no subcommands",
testExec(testCase{impl: cmd, command: "foo"}, "empty command category", ""),
)
cmd = Group("foo", Command("bar-baz", func() {}), Command("qux quux", func() {}))
t.Run(
"invalid subcommand name",
testExec(testCase{impl: cmd, command: "foo bar-baz"}, "command name is not a valid", ""),
)
cmd = Group("foo", Command("bar", func() {}), Command("bar", func() {}))
t.Run(
"duplicate subcommand name",
testExec(testCase{impl: cmd, command: "foo bar"}, "subcommand name conflict", ""),
)
cmd = Command("foo", func() {}, Default(Command("bar", func() {})), Command("bar", func() {}))
t.Run(
"implementation conflict",
testExec(
testCase{impl: cmd, command: "foo bar"},
"default subcommand defined for a command that has an explicit implementation",
"",
),
)
cmd = Group("foo", Default(Command("bar", func() {})), Default(Command("baz", func() {})))
t.Run(
"multiple defaults",
testExec(
testCase{impl: cmd, command: "foo bar"},
"multiple default subcommands",
"",
),
)
cmd = Command("foo", func() {}, Command("bar", func() {}), Command("baz", func(chan int) {}))
t.Run(
"invalid subcommand",
testExec(
testCase{impl: cmd, command: "foo bar"},
"foo:",
"",
),
)
})
t.Run("short forms", func(t *testing.T) {
type (
s0 struct {
Foo int
Qux int
}
s1 struct{ Bar int }
s2 struct{ Baz int }
)
f0 := func(a s0) int { return a.Foo }
f1 := func(a s1) int { return a.Bar }
f2 := func(a s2) int { return a.Baz }
cmd := ShortForm(Command("foo", f0, ShortForm(Command("bar", f1), "a", "bar")), "a", "foo")
t.Run(
"same short form for different options",
testExec(testCase{impl: cmd, command: "foo"}, "same short form for different options", ""),
)
cmd = ShortForm(Command("foo", f0), "a", "foo", "a", "qux")
t.Run(
"same short form for different options on the same command",
testExec(testCase{impl: cmd, command: "foo"}, "same short form for different options", ""),
)
cmd = ShortForm(Command("foo", f0, Command("bar", f1)), "a", "foo", "a", "qux")
t.Run(
"same short form for different options on the same command with subcommand",
testExec(testCase{impl: cmd, command: "foo"}, "same short form for different options", ""),
)
cmd = Command(
"foo",
f0,
ShortForm(Command("bar", f1), "a", "bar"),
ShortForm(Command("baz", f2), "a", "baz"),
)
t.Run(
"same short form for different options on different branches in the tree",
testExec(testCase{impl: cmd, command: "foo"}, "same short form for different options", ""),
)
cmd = ShortForm(Command("foo", f0, ShortForm(Command("bar", f1), "b", "baz")), "a", "foo")
t.Run(
"unmapped short form",
testExec(testCase{impl: cmd, command: "foo"}, "unmapped short forms", ""),
)
cmd = ShortForm(
Command(
"foo",
f0,
ShortForm(Command("bar", f1), "b", "baz"),
ShortForm(Command("qux", f1), "b", "qux"),
),
"a",
"foo",
)
t.Run(
"conflicting unmapped",
testExec(
testCase{impl: cmd, command: "foo"},
"using the same short form for different options",
"",
),
)
cmd = ShortForm(Command("foo", f0), "ab", "foo")
t.Run(
"short form not a single character",
testExec(testCase{impl: cmd, command: "foo"}, "invalid short form", ""),
)
cmd = ShortForm(Command("foo", f0), "6", "foo")
t.Run(
"short form not a single character",
testExec(testCase{impl: cmd, command: "foo"}, "invalid short form", ""),
)
cmd = ShortForm(Group("foo", Command("bar", f1)), "b", "bar")
t.Run(
"short form with command category",
testExec(testCase{impl: cmd, command: "foo bar"}, "", "0"),
)
})
t.Run("bool options", func(t *testing.T) {
type s0 struct {
Foo int
Bar bool
Baz bool
}
type s1 struct {
Foo int
Bar bool
Baz bool
Help bool
}
f0 := func(a s0, b, c string) string {
return fmt.Sprint(a.Foo) + fmt.Sprint(a.Bar) + fmt.Sprint(a.Baz) + b + c
}
f1 := func(a s1, b, c string) string {
return fmt.Sprint(a.Foo) + fmt.Sprint(a.Bar) + fmt.Sprint(a.Baz) + fmt.Sprint(a.Help) + b + c
}
cmd := ShortForm(Command("foo", f0), "b", "bar")
t.Run(
"no help in fields and no help in short forms",
testExec(testCase{impl: cmd, command: "foo --foo 42 -b qux --baz quux"}, "", "42truetruequxquux"),
)
t.Run(
"no help in fields and no help in short forms help",
testExec(testCase{impl: cmd, command: "foo --help", contains: true}, "", "Synopsis"),
)
cmd = ShortForm(Command("foo", f0), "h", "help")
t.Run(
"help from short forms",
testExec(testCase{impl: cmd, command: "foo -h", contains: true}, "", "Synopsis"),
)
cmd = ShortForm(Command("foo", f1), "b", "bar")
t.Run(
"help taken by a field",
testExec(testCase{impl: cmd, command: "foo --help qux quux"}, "", "0falsefalsetruequxquux"),
)
cmd = ShortForm(Command("foo", f1), "h", "bar")
t.Run(
"help taken by a field and help short form used for another field",
testExec(testCase{impl: cmd, command: "foo --help qux -h quux"}, "", "0truefalsetruequxquux"),
)
})
}

View File

@ -6,7 +6,7 @@ import (
"testing"
)
func TestCommand(t *testing.T) {
func TestCommandLine(t *testing.T) {
type f struct {
One string
SecondField int
@ -93,7 +93,7 @@ func TestCommand(t *testing.T) {
"bool last",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two", "c", "three"),
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two", "c", "three"),
command: "foo -abc true",
},
"",
@ -105,7 +105,7 @@ func TestCommand(t *testing.T) {
"string last",
testExec(
testCase{
impl: ShortForm(Command("foo", fm), "a", "one", "b", "two", "c", "three"),
impl: ShortForm(Command("foo", fm), "a", "one", "b", "two", "c", "three"),
command: "foo -abc bar",
},
"",
@ -119,7 +119,7 @@ func TestCommand(t *testing.T) {
"bools, short",
testExec(
testCase{
impl: ShortForm(Command("foo", fl), "a", "one"),
impl: ShortForm(Command("foo", fl), "a", "one"),
command: "foo -a -a -a",
},
"",
@ -131,7 +131,7 @@ func TestCommand(t *testing.T) {
"bools, short, combined",
testExec(
testCase{
impl: ShortForm(Command("foo", fl), "a", "one"),
impl: ShortForm(Command("foo", fl), "a", "one"),
command: "foo -aaa",
},
"",
@ -143,7 +143,7 @@ func TestCommand(t *testing.T) {
"bools, short, explicit",
testExec(
testCase{
impl: ShortForm(Command("foo", fl), "a", "one"),
impl: ShortForm(Command("foo", fl), "a", "one"),
command: "foo -a true -a true -a true",
},
"",
@ -155,7 +155,7 @@ func TestCommand(t *testing.T) {
"bools, short, combined, last explicit",
testExec(
testCase{
impl: ShortForm(Command("foo", fl), "a", "one"),
impl: ShortForm(Command("foo", fl), "a", "one"),
command: "foo -aaa true",
},
"",
@ -167,7 +167,7 @@ func TestCommand(t *testing.T) {
"bools, long",
testExec(
testCase{
impl: fl,
impl: fl,
command: "foo --one --one --one",
},
"",
@ -179,18 +179,18 @@ func TestCommand(t *testing.T) {
"bools, long, explicit",
testExec(
testCase{
impl: fl,
impl: fl,
command: "foo --one true --one true --one true",
},
"",
"true,true,true;"),
"",
"true,true,true;"),
)
t.Run(
"mixd, short",
testExec(
testCase{
impl: ShortForm(Command("foo", fl), "a", "one", "b", "two"),
impl: ShortForm(Command("foo", fl), "a", "one", "b", "two"),
command: "foo -a -b bar",
},
"",
@ -202,7 +202,7 @@ func TestCommand(t *testing.T) {
"mixed, short, combined",
testExec(
testCase{
impl: ShortForm(Command("foo", fl), "a", "one", "b", "two"),
impl: ShortForm(Command("foo", fl), "a", "one", "b", "two"),
command: "foo -ab bar",
},
"",
@ -214,7 +214,7 @@ func TestCommand(t *testing.T) {
"mixed, long",
testExec(
testCase{
impl: fl,
impl: fl,
command: "foo --one --two bar",
},
"",
@ -226,7 +226,7 @@ func TestCommand(t *testing.T) {
"mixed, long, explicit",
testExec(
testCase{
impl: fl,
impl: fl,
command: "foo --one true --two bar",
},
"",
@ -240,7 +240,7 @@ func TestCommand(t *testing.T) {
"short",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one"),
impl: ShortForm(Command("foo", fb), "a", "one"),
command: "foo -a",
},
"",
@ -252,7 +252,7 @@ func TestCommand(t *testing.T) {
"short, multiple",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two", "c", "three"),
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two", "c", "three"),
command: "foo -a -b -c",
},
"",
@ -264,7 +264,7 @@ func TestCommand(t *testing.T) {
"short, combined",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two", "c", "three"),
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two", "c", "three"),
command: "foo -abc",
},
"",
@ -276,7 +276,7 @@ func TestCommand(t *testing.T) {
"short, combined, multiple",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two", "c", "three", "d", "four"),
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two", "c", "three", "d", "four"),
command: "foo -ab -cd",
},
"",
@ -288,7 +288,7 @@ func TestCommand(t *testing.T) {
"short, multiple values",
testExec(
testCase{
impl: ShortForm(Command("foo", flb), "a", "one", "b", "two", "c", "three"),
impl: ShortForm(Command("foo", flb), "a", "one", "b", "two", "c", "three"),
command: "foo -aba -cab",
},
"",
@ -300,7 +300,7 @@ func TestCommand(t *testing.T) {
"long",
testExec(
testCase{
impl: fb,
impl: fb,
command: "foo --one",
},
"",
@ -312,7 +312,7 @@ func TestCommand(t *testing.T) {
"long, multiple",
testExec(
testCase{
impl: fb,
impl: fb,
command: "foo --one --two --three",
},
"",
@ -324,7 +324,7 @@ func TestCommand(t *testing.T) {
"long, multiple values",
testExec(
testCase{
impl: flb,
impl: flb,
command: "foo --one --two --one",
},
"",
@ -338,7 +338,7 @@ func TestCommand(t *testing.T) {
"short, true",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one"),
impl: ShortForm(Command("foo", fb), "a", "one"),
command: "foo -a true",
},
"",
@ -350,7 +350,7 @@ func TestCommand(t *testing.T) {
"short, false",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one"),
impl: ShortForm(Command("foo", fb), "a", "one"),
command: "foo -a false",
},
"",
@ -362,7 +362,7 @@ func TestCommand(t *testing.T) {
"short, with eq",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one"),
impl: ShortForm(Command("foo", fb), "a", "one"),
command: "foo -a=true",
},
"",
@ -374,7 +374,7 @@ func TestCommand(t *testing.T) {
"short, true variant, capital",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a","one"),
impl: ShortForm(Command("foo", fb), "a", "one"),
command: "foo -a True",
},
"",
@ -386,7 +386,7 @@ func TestCommand(t *testing.T) {
"short, true variant, 1",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one"),
impl: ShortForm(Command("foo", fb), "a", "one"),
command: "foo -a 1",
},
"",
@ -398,7 +398,7 @@ func TestCommand(t *testing.T) {
"short, false variant, 0",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one"),
impl: ShortForm(Command("foo", fb), "a", "one"),
command: "foo -a 0",
},
"",
@ -410,7 +410,7 @@ func TestCommand(t *testing.T) {
"short, combined",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two"),
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two"),
command: "foo -ab true",
},
"",
@ -422,7 +422,7 @@ func TestCommand(t *testing.T) {
"short, combined, multiple",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two", "c", "three", "d", "four"),
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two", "c", "three", "d", "four"),
command: "foo -ab true -cd true",
},
"",
@ -434,7 +434,7 @@ func TestCommand(t *testing.T) {
"long",
testExec(
testCase{
impl: fb,
impl: fb,
command: "foo --one true",
},
"",
@ -446,7 +446,7 @@ func TestCommand(t *testing.T) {
"long, false",
testExec(
testCase{
impl: fb,
impl: fb,
command: "foo --one false",
},
"",
@ -458,7 +458,7 @@ func TestCommand(t *testing.T) {
"logn, with eq",
testExec(
testCase{
impl: fb,
impl: fb,
command: "foo --one=true",
},
"",
@ -469,7 +469,7 @@ func TestCommand(t *testing.T) {
"long, mixed, first",
testExec(
testCase{
impl: fb,
impl: fb,
command: "foo --one false --two",
},
"",
@ -481,7 +481,7 @@ func TestCommand(t *testing.T) {
"long, mixed, last",
testExec(
testCase{
impl: fb,
impl: fb,
command: "foo --one --two false",
},
"",
@ -495,7 +495,7 @@ func TestCommand(t *testing.T) {
"short, implicit",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one"),
impl: ShortForm(Command("foo", fb), "a", "one"),
command: "foo -a",
},
"",
@ -506,7 +506,7 @@ func TestCommand(t *testing.T) {
"short, explicit",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a", "one"),
impl: ShortForm(Command("foo", fb), "a", "one"),
command: "foo -a true",
},
"",
@ -518,7 +518,7 @@ func TestCommand(t *testing.T) {
"short, automatic positional",
testExec(
testCase{
impl: ShortForm(Command("foo", fbp), "a", "one"),
impl: ShortForm(Command("foo", fbp), "a", "one"),
command: "foo -a bar",
},
"",
@ -530,7 +530,7 @@ func TestCommand(t *testing.T) {
"short, combined",
testExec(
testCase{
impl: ShortForm(Command("foo", fb), "a","one", "b", "two"),
impl: ShortForm(Command("foo", fb), "a", "one", "b", "two"),
command: "foo -ab true",
},
"",
@ -542,7 +542,7 @@ func TestCommand(t *testing.T) {
"short, combined, automatic positional",
testExec(
testCase{
impl: ShortForm(Command("foo", fbp), "a", "one", "b", "two"),
impl: ShortForm(Command("foo", fbp), "a", "one", "b", "two"),
command: "foo -ab bar",
},
"",
@ -553,7 +553,7 @@ func TestCommand(t *testing.T) {
"long, implicit",
testExec(
testCase{
impl: fb,
impl: fb,
command: "foo --one",
},
"",
@ -565,7 +565,7 @@ func TestCommand(t *testing.T) {
"long, explicit",
testExec(
testCase{
impl: fb,
impl: fb,
command: "foo --one true",
},
"",
@ -577,7 +577,7 @@ func TestCommand(t *testing.T) {
"long, automatic positional",
testExec(
testCase{
impl: fbp,
impl: fbp,
command: "foo --one bar",
},
"",
@ -591,7 +591,7 @@ func TestCommand(t *testing.T) {
"basic",
testExec(
testCase{
impl: fp,
impl: fp,
command: "foo bar baz",
},
"",
@ -603,7 +603,7 @@ func TestCommand(t *testing.T) {
"explicit",
testExec(
testCase{
impl: fp,
impl: fp,
command: "foo -- bar baz",
},
"",
@ -615,7 +615,7 @@ func TestCommand(t *testing.T) {
"mixed",
testExec(
testCase{
impl: fp,
impl: fp,
command: "foo bar -- baz",
},
"",
@ -627,7 +627,7 @@ func TestCommand(t *testing.T) {
"with option",
testExec(
testCase{
impl: fp,
impl: fp,
command: "foo bar --second-field 42 baz",
},
"",
@ -639,7 +639,7 @@ func TestCommand(t *testing.T) {
"with bool option at the end",
testExec(
testCase{
impl: fbp,
impl: fbp,
command: "foo bar baz --one",
},
"",
@ -651,7 +651,7 @@ func TestCommand(t *testing.T) {
"with expected bool, implicit",
testExec(
testCase{
impl: fbp,
impl: fbp,
command: "foo bar --one baz",
},
"",
@ -663,7 +663,7 @@ func TestCommand(t *testing.T) {
"with expected bool, explicit",
testExec(
testCase{
impl: fbp,
impl: fbp,
command: "foo bar --one true baz",
},
"",
@ -675,7 +675,7 @@ func TestCommand(t *testing.T) {
"option format",
testExec(
testCase{
impl: fbp,
impl: fbp,
command: "foo -- --one",
},
"",
@ -705,7 +705,7 @@ func TestCommand(t *testing.T) {
"full",
testExec(
testCase{
impl: ShortForm(Command("foo", fs), "a", "foo", "b", "bar"),
impl: ShortForm(Command("foo", fs), "a", "foo", "b", "bar"),
command: "foo -ab --bar baz -b --qux --quux corge -- grault",
},
"",
@ -719,7 +719,7 @@ func TestCommand(t *testing.T) {
"capital letters",
testExec(
testCase{
impl: fp,
impl: fp,
command: "foo --One bar",
},
"",
@ -731,7 +731,7 @@ func TestCommand(t *testing.T) {
"digit in option name",
testExec(
testCase{
impl: fd,
impl: fd,
command: "foo --one-2",
},
"",
@ -743,7 +743,7 @@ func TestCommand(t *testing.T) {
"dash in option name",
testExec(
testCase{
impl: ff,
impl: ff,
command: "foo --second-field 42",
},
"",
@ -755,7 +755,7 @@ func TestCommand(t *testing.T) {
"unpexpected character",
testExec(
testCase{
impl: fp,
impl: fp,
command: "foo --one#",
},
"",
@ -767,7 +767,7 @@ func TestCommand(t *testing.T) {
"invalid short option set",
testExec(
testCase{
impl: ShortForm(Command("foo", fp), "a", "one", "b", "one", "c", "second-field"),
impl: ShortForm(Command("foo", fp), "a", "one", "b", "one", "c", "second-field"),
command: "foo -aBc",
},
"",
@ -779,7 +779,7 @@ func TestCommand(t *testing.T) {
"positional separator, no value",
testExec(
testCase{
impl: fp,
impl: fp,
command: "foo --one bar --",
},
"",
@ -791,7 +791,7 @@ func TestCommand(t *testing.T) {
"positional separator, expecting value",
testExec(
testCase{
impl: fp,
impl: fp,
command: "foo --one --",
},
"--one",
@ -803,7 +803,7 @@ func TestCommand(t *testing.T) {
"shot flag set, expecting value",
testExec(
testCase{
impl: ShortForm(Command("foo", fp), "b", "second-field"),
impl: ShortForm(Command("foo", fp), "b", "second-field"),
command: "foo --one -b",
},
"--one",
@ -817,7 +817,7 @@ func TestCommand(t *testing.T) {
"bools",
testExec(
testCase{
impl: fl,
impl: fl,
command: "foo --one --one false --one",
},
"",
@ -829,7 +829,7 @@ func TestCommand(t *testing.T) {
"strings",
testExec(
testCase{
impl: fl,
impl: fl,
command: "foo --two 1 --two 2 --two 3",
},
"",
@ -843,7 +843,7 @@ func TestCommand(t *testing.T) {
"named",
testExec(
testCase{
impl: Group("foo", Command("bar", ff), Command("baz", ff)),
impl: Group("foo", Command("bar", ff), Command("baz", ff)),
command: "foo baz",
},
"",
@ -855,7 +855,7 @@ func TestCommand(t *testing.T) {
"default",
testExec(
testCase{
impl: Group("foo", Command("bar", ff), Default(Command("baz", ff))),
impl: Group("foo", Command("bar", ff), Default(Command("baz", ff))),
command: "foo",
},
"",
@ -869,7 +869,7 @@ func TestCommand(t *testing.T) {
"short form not defined",
testExec(
testCase{
impl: fm,
impl: fm,
command: "foo -h",
},
"foo help",
@ -881,7 +881,7 @@ func TestCommand(t *testing.T) {
"short form not help",
testExec(
testCase{
impl: ShortForm(Command("foo", fm), "h", "one"),
impl: ShortForm(Command("foo", fm), "h", "one"),
command: "foo -h",
},
"",
@ -893,8 +893,8 @@ func TestCommand(t *testing.T) {
"short form",
testExec(
testCase{
impl: ShortForm(Command("foo", fm), "h", "help"),
command: "foo -h",
impl: ShortForm(Command("foo", fm), "h", "help"),
command: "foo -h",
contains: true,
},
"",
@ -906,8 +906,8 @@ func TestCommand(t *testing.T) {
"long form",
testExec(
testCase{
impl: fm,
command: "foo --help",
impl: fm,
command: "foo --help",
contains: true,
},
"",

View File

@ -104,9 +104,9 @@ func TestConfig(t *testing.T) {
"discard in previous doc",
testExec(
testCase{
impl: fm,
impl: fm,
mergeConf: []string{"one=bar\nsecond-var=baz", "one\nsecond-var=qux"},
command: "foo",
command: "foo",
},
"",
";qux",
@ -117,9 +117,9 @@ func TestConfig(t *testing.T) {
"discard in previous same doc",
testExec(
testCase{
impl: fm,
impl: fm,
mergeConf: []string{"one=bar\nsecond-var=baz", "one\nsecond-var=qux\nsecond-var"},
command: "foo",
command: "foo",
},
"",
";",
@ -138,8 +138,8 @@ func TestConfig(t *testing.T) {
"comments",
testExec(
testCase{
impl: fm,
conf: "# comment on a line\none=bar # comment after an entry",
impl: fm,
conf: "# comment on a line\none=bar # comment after an entry",
command: "foo",
},
"",

View File

@ -11,5 +11,13 @@ func debug(a ...any) {
return
}
notation.Fprintln(os.Stderr, a...)
}
func debugw(a ...any) {
if !testing.Testing() {
return
}
notation.Fprintlnw(os.Stderr, a...)
}

View File

@ -10,7 +10,6 @@ import (
)
func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, conf Config, env, args []string) {
cmd = insertHelp(cmd)
_, cmd.name = filepath.Split(args[0])
if err := validateCommand(cmd, conf); err != nil {
fmt.Fprintf(stderr, "program error: %v\n", err)
@ -37,6 +36,8 @@ func exec(stdin io.Reader, stdout, stderr io.Writer, exit func(int), cmd Cmd, co
return
}
cmd = insertHelp(cmd)
// will need root command for the config and the env:
rootCmd := cmd
cmd, fullCmd, args := selectCommand(cmd, args[1:])

View File

@ -9,13 +9,14 @@ import (
)
type testCase struct {
impl any
stdin string
conf string
mergeConf []string
env string
command string
contains bool
impl any
stdin string
conf string
mergeConfTyped []Config
mergeConf []string
env string
command string
contains bool
}
func testExec(test testCase, err string, expect ...string) func(*testing.T) {
@ -23,9 +24,11 @@ func testExec(test testCase, err string, expect ...string) func(*testing.T) {
var exitCode int
exit := func(code int) { exitCode = code }
var stdinr io.Reader
if test.stdin != "" {
stdinr = bytes.NewBuffer([]byte(test.stdin))
var stdin io.Reader
if test.stdin == "" {
stdin = bytes.NewBuffer(nil)
} else {
stdin = bytes.NewBufferString(test.stdin)
}
stdout := bytes.NewBuffer(nil)
@ -33,11 +36,28 @@ func testExec(test testCase, err string, expect ...string) func(*testing.T) {
cmd := wrap(test.impl)
e := strings.Split(test.env, ";")
a := strings.Split(test.command, " ")
var conf Config
if test.conf != "" && len(test.mergeConf) > 0 {
zeroOrOne := func(b ...bool) bool {
var one bool
for _, bi := range b {
if bi && one {
return false
}
if bi {
one = true
}
}
return true
}
if !zeroOrOne(test.conf != "", len(test.mergeConf) > 0, len(test.mergeConfTyped) > 0) {
t.Fatal("test error: conflicting test config")
} else if test.conf != "" {
}
var conf Config
if test.conf != "" {
conf = Config{test: test.conf}
} else if len(test.mergeConf) > 0 {
var c []Config
@ -46,9 +66,11 @@ func testExec(test testCase, err string, expect ...string) func(*testing.T) {
}
conf = MergeConfig(c...)
} else if len(test.mergeConfTyped) > 0 {
conf = MergeConfig(test.mergeConfTyped...)
}
exec(stdinr, stdout, stderr, exit, cmd, conf, e, a)
exec(stdin, stdout, stderr, exit, cmd, conf, e, a)
if exitCode != 0 && err == "" {
t.Fatal("non-zero exit code:", stderr.String())
}

2
go.mod
View File

@ -3,7 +3,7 @@ module code.squareroundforest.org/arpio/wand
go 1.25.0
require (
code.squareroundforest.org/arpio/bind v0.0.0-20250903234821-f3e17035cd36
code.squareroundforest.org/arpio/bind v0.0.0-20250905213330-4591a086be1e
code.squareroundforest.org/arpio/docreflect v0.0.0-20250904132730-afd27063724e
code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2
code.squareroundforest.org/arpio/treerack v0.0.0-20250820014405-1d956dcc6610

10
go.sum
View File

@ -1,11 +1,5 @@
code.squareroundforest.org/arpio/bind v0.0.0-20250901011104-bcadfd8b71fc h1:nu5YXVLDrRzN9Ea5agXmhxFILyVAPyoED25ksTYC9ws=
code.squareroundforest.org/arpio/bind v0.0.0-20250901011104-bcadfd8b71fc/go.mod h1:tTCmCwFABKNm3PO0Dclsp4zWhNQFTfg9+uSrgoarZFI=
code.squareroundforest.org/arpio/bind v0.0.0-20250903223305-8683d8ba4074 h1:OTzn0dMou+6m2rw70g7fIylQLHUTu75noAX3lbCYMqw=
code.squareroundforest.org/arpio/bind v0.0.0-20250903223305-8683d8ba4074/go.mod h1:tTCmCwFABKNm3PO0Dclsp4zWhNQFTfg9+uSrgoarZFI=
code.squareroundforest.org/arpio/bind v0.0.0-20250903234821-f3e17035cd36 h1:8TB3ABJVV0eEdnWl+dJ3Hg4lGe+BlgNPgcW5p9yZnrQ=
code.squareroundforest.org/arpio/bind v0.0.0-20250903234821-f3e17035cd36/go.mod h1:tTCmCwFABKNm3PO0Dclsp4zWhNQFTfg9+uSrgoarZFI=
code.squareroundforest.org/arpio/docreflect v0.0.0-20250831183400-d26ecc663a30 h1:QUCgxUEA5/ng7GwRnzb/WezmFQXSHXl48GdLJc0KC5k=
code.squareroundforest.org/arpio/docreflect v0.0.0-20250831183400-d26ecc663a30/go.mod h1:/3xQI36oJG8qLBxT2fSS61P5/+i1T64fTX9GHRh8XhA=
code.squareroundforest.org/arpio/bind v0.0.0-20250905213330-4591a086be1e h1:DkOYkD12OWMAczreQESVQF7b1KsyBQq4G700oGxNy08=
code.squareroundforest.org/arpio/bind v0.0.0-20250905213330-4591a086be1e/go.mod h1:tTCmCwFABKNm3PO0Dclsp4zWhNQFTfg9+uSrgoarZFI=
code.squareroundforest.org/arpio/docreflect v0.0.0-20250904132730-afd27063724e h1:f7wtGAmuTYH/VTn92sBTtKhs463q+DTtW2yKgst2kl8=
code.squareroundforest.org/arpio/docreflect v0.0.0-20250904132730-afd27063724e/go.mod h1:/3xQI36oJG8qLBxT2fSS61P5/+i1T64fTX9GHRh8XhA=
code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2 h1:S4mjQHL70CuzFg1AGkr0o0d+4M+ZWM0sbnlYq6f0b3I=

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,6 @@
use a type cache
support unix timestamps in bind and in reflect
verify that duration can be parsed from integer strings
test:
- nil return values
- options in variadic

View File

@ -145,10 +145,30 @@ func compatibleTypes(t ...bind.FieldType) bool {
}
switch t[0] {
case bind.Any:
case bind.String, bind.Any:
return compatibleTypes(t[1:]...)
}
switch t[1] {
case bind.String, bind.Any:
return compatibleTypes(t[1:]...)
}
switch t[0] {
case bind.Bool:
switch t[1] {
case bind.Bool:
return compatibleTypes(t[1:]...)
default:
return false
}
}
switch t[1] {
case bind.Bool:
return false
default:
return t[0] == t[1] && compatibleTypes(t[1:]...)
return compatibleTypes(t[1:]...)
}
}

View File

@ -3,6 +3,7 @@ package wand
import (
"bytes"
"code.squareroundforest.org/arpio/wand/internal/tests/testlib"
"fmt"
"io"
"testing"
"time"
@ -96,22 +97,94 @@ func TestReflect(t *testing.T) {
t.Run("compatible types", func(t *testing.T) {
type s0 struct {
FooBar int
Foo struct{ Bar string }
Foo struct{ Bar bool }
}
type s0a struct {
FooBar bool
Foo struct{ Bar int }
}
type s1 struct {
FooBar int
Foo struct{ Bar int }
}
f := func(a s0) int { return a.FooBar + len(a.Foo.Bar) }
f := func(a s0) string { return fmt.Sprint(a.FooBar) + fmt.Sprint(a.Foo.Bar) }
fa := func(a s0a) string { return fmt.Sprint(a.FooBar) + fmt.Sprint(a.Foo.Bar) }
g := func(a s1) int { return a.FooBar + a.Foo.Bar }
t.Run("incompatible", testExec(testCase{impl: f, command: "foo --foo-bar 42"}, "duplicate fields with different types", ""))
t.Run("incompatible", testExec(testCase{impl: fa, command: "foo --foo-bar 42"}, "duplicate fields with different types", ""))
t.Run("compatible", testExec(testCase{impl: g, command: "foo --foo-bar 42"}, "", "84"))
type s2 struct {
FooBar any
Foo struct{ Bar int }
}
type s2a struct {
FooBar int
Foo struct{ Bar any }
}
h := func(a s2) int { return len(a.FooBar.(string)) + a.Foo.Bar }
t.Run("any interface", testExec(testCase{impl: h, command: "foo --foo-bar 42"}, "", "44"))
ha := func(a s2a) int { return a.FooBar + len(a.Foo.Bar.(string)) }
t.Run("any interface", testExec(testCase{impl: ha, command: "foo --foo-bar 42"}, "", "44"))
type s3 struct {
FooBar string
Foo struct{ Bar int }
}
i := func(a s2) string { return fmt.Sprint(a.FooBar) + fmt.Sprint(a.Foo.Bar) }
t.Run("string", testExec(testCase{impl: i, command: "foo --foo-bar 42"}, "", "4242"))
type s4 struct {
FooBar bool
Foo struct{ Bar bool }
}
j := func(a s4) string { return fmt.Sprint(a.FooBar) + fmt.Sprint(a.Foo.Bar) }
t.Run("bool", testExec(testCase{impl: j, command: "foo --foo-bar"}, "", "truetrue"))
type s5 struct {
FooBar time.Duration
Foo struct{ Bar time.Duration }
}
k := func(a s5) string { return fmt.Sprint(a.FooBar) + fmt.Sprint(a.Foo.Bar) }
t.Run("duration and duration", testExec(testCase{impl: k, command: "foo --foo-bar 9s"}, "", "9s9s"))
type s6 struct {
FooBar time.Duration
Foo struct{ Bar int }
}
l := func(a s6) string { return fmt.Sprint(a.FooBar) + fmt.Sprint(a.Foo.Bar) }
t.Run(
"duration and numeric",
testExec(testCase{impl: l, command: "foo --foo-bar 9000000000"}, "", "9s9000000000"),
)
type s7 struct {
FooBar time.Time
Foo struct{ Bar time.Time }
}
m := func(a s7) string { return fmt.Sprint(a.FooBar.Unix()) + fmt.Sprint(a.Foo.Bar.Unix()) }
t.Run("time and time", testExec(testCase{impl: m, command: "foo --foo-bar 1757097011"}, "", "17570970111757097011"))
type s8 struct {
FooBar time.Time
Foo struct{ Bar int }
}
n := func(a s8) string { return fmt.Sprint(a.FooBar.Unix()) + fmt.Sprint(a.Foo.Bar) }
t.Run(
"time and numeric",
testExec(testCase{impl: n, command: "foo --foo-bar 1757097011"}, "", "17570970111757097011"),
)
})
t.Run("bind failure", func(t *testing.T) {