Something went wrong. Try again.
Simple, intuitive CLI framework for Go pkg.go.dev/go.followtheprocess.codes/cli
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299package cli_test
import ( "bytes" "context" goflag "flag" "fmt" "io" "math/rand/v2" "os" "slices" "testing"
"go.followtheprocess.codes/cli" "go.followtheprocess.codes/cli/flag" "go.followtheprocess.codes/snapshot" "go.followtheprocess.codes/test")
var ( debug = goflag.Bool("debug", false, "Print debug output during tests") update = goflag.Bool("update", false, "Update golden files"))
func TestExecute(t *testing.T) { tests := []struct { name string // Name of the test case stdout string // Expected output to stdout stderr string // Expected output to stderr options []cli.Option // Options to apply to the test command wantErr bool // Whether we want an error or not }{ { name: "simple", stdout: "My arguments were: [hello there]\nForce was: false\n", stderr: "", options: []cli.Option{ cli.OverrideArgs([]string{"hello", "there"}), cli.Arg(new(string), "first", "The first word"), // Expect the positional arguments cli.Arg(new(string), "second", "The second word"), cli.Stdin(os.Stdin), // Set stdin for the lols }, wantErr: false, }, { name: "simple with flag", stdout: "My arguments were: [hello there]\nForce was: true\n", stderr: "", options: []cli.Option{ cli.OverrideArgs([]string{"hello", "there", "--force"}), cli.Arg(new(string), "first", "The first word"), // Expect the positional arguments cli.Arg(new(string), "second", "The second word"), }, wantErr: false, }, { name: "bad flag", stdout: "", stderr: "", options: []cli.Option{ cli.OverrideArgs([]string{"arg1", "arg2", "arg3", "-]force"}), cli.Arg(new(string), "first", "The first arg"), // Expect the positional arguments cli.Arg(new(string), "second", "The second arg"), cli.Arg(new(string), "third", "The third arg"), }, wantErr: true, }, { name: "bad arg", stdout: "", stderr: "", options: []cli.Option{ cli.OverrideArgs([]string{"arg1", "notanumber"}), // Provided arg for 'second' is not an int cli.Arg(new(string), "first", "The first arg"), cli.Arg(new(int), "second", "The second arg"), }, wantErr: true, }, { name: "missing required argument", stdout: "", stderr: "", options: []cli.Option{ cli.OverrideArgs([]string{"hello"}), // "second" is not provided cli.Arg(new(string), "first", "The first word"), // Expect the positional arguments cli.Arg(new(string), "second", "The second word"), }, wantErr: true, }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var force bool
stderr := &bytes.Buffer{} stdout := &bytes.Buffer{}
// Test specific overrides to the options in the table options := []cli.Option{ cli.Stdout(stdout), cli.Stderr(stderr), cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintf(cmd.Stdout(), "My arguments were: %v\nForce was: %v\n", cmd.Args(), force)
return nil }), cli.Flag(&force, "force", 'f', "Force something"), }
cmd, err := cli.New("test", slices.Concat(options, tt.options)...) test.Ok(t, err)
err = cmd.Execute(t.Context()) test.WantErr(t, err, tt.wantErr)
test.Equal(t, stdout.String(), tt.stdout) test.Equal(t, stderr.String(), tt.stderr) }) }}
func TestSubCommandExecute(t *testing.T) { tests := []struct { name string // Test case name stdout string // Expected stdout stderr string // Expected stderr args []string // Args passed to root command extra []string // Extra args after "--" if present sub1Options []cli.Option // Options for subcommand 1 sub2Options []cli.Option // Options for subcommand 1 wantErr bool // Whether or not we wanted an error }{ { name: "invoke sub1 no flags", stdout: "Hello from sub1, my args were: [my subcommand args], force was false, something was <empty>, extra args: []", stderr: "", args: []string{"sub1", "my", "subcommand", "args"}, sub1Options: []cli.Option{ cli.Arg(new(string), "one", "First arg"), cli.Arg(new(string), "two", "Second arg"), cli.Arg(new(string), "three", "Third arg"), }, wantErr: false, }, { name: "invoke sub2 no flags", stdout: "Hello from sub2, my args were: [my different args], delete was false, number was -1", stderr: "", args: []string{"sub2", "my", "different", "args"}, sub2Options: []cli.Option{ cli.Arg(new(string), "one", "First arg"), cli.Arg(new(string), "two", "Second arg"), cli.Arg(new(string), "three", "Third arg"), }, wantErr: false, }, { name: "invoke sub1 with flags", stdout: "Hello from sub1, my args were: [my subcommand args], force was true, something was here, extra args: []", stderr: "", args: []string{"sub1", "my", "subcommand", "args", "--force", "--something", "here"}, sub1Options: []cli.Option{ cli.Arg(new(string), "one", "First arg"), cli.Arg(new(string), "two", "Second arg"), cli.Arg(new(string), "three", "Third arg"), }, wantErr: false, }, { name: "invoke sub1 with arg terminator", stdout: "Hello from sub1, my args were: [my subcommand args more args here], force was true, something was here, extra args: [more args here]", stderr: "", args: []string{ "sub1", "my", "subcommand", "args", "--force", "--something", "here", "--", "more", "args", "here", }, sub1Options: []cli.Option{ cli.Arg(new(string), "one", "First arg"), cli.Arg(new(string), "two", "Second arg"), cli.Arg(new(string), "three", "Third arg"), cli.Arg(new(string), "four", "Fourth arg"), cli.Arg(new(string), "five", "Fifth arg"), cli.Arg(new(string), "six", "Sixth arg"), }, wantErr: false, }, { name: "invoke sub1 with sub1 in the arg list", stdout: "Hello from sub1, my args were: [my sub1 args sub1 more args here], force was true, something was here, extra args: []", stderr: "", args: []string{ "sub1", "my", "sub1", "args", "sub1", "--force", "--something", "here", "more", "args", "here", }, sub1Options: []cli.Option{ cli.Arg(new(string), "one", "First arg"), cli.Arg(new(string), "two", "Second arg"), cli.Arg(new(string), "three", "Third arg"), cli.Arg(new(string), "four", "Fourth arg"), cli.Arg(new(string), "five", "Fifth arg"), cli.Arg(new(string), "six", "Sixth arg"), cli.Arg(new(string), "seven", "Seventh arg"), }, wantErr: false, }, { name: "invoke sub1 with sub1 as a flag value", stdout: "Hello from sub1, my args were: [my subcommand args more args here], force was true, something was sub2, extra args: []", stderr: "", args: []string{ "sub1", "my", "subcommand", "args", "--force", "--something", "sub2", "more", "args", "here", }, sub1Options: []cli.Option{ cli.Arg(new(string), "one", "First arg"), cli.Arg(new(string), "two", "Second arg"), cli.Arg(new(string), "three", "Third arg"), cli.Arg(new(string), "four", "Fourth arg"), cli.Arg(new(string), "five", "Fifth arg"), cli.Arg(new(string), "six", "Sixth arg"), }, wantErr: false, }, { name: "invoke root with no args", stdout: "", stderr: "", args: []string{}, wantErr: true, }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var ( force bool something string deleteMe bool number int stdoutBuf = &bytes.Buffer{} stderrBuf = &bytes.Buffer{} )
sub1 := func() (*cli.Command, error) { defaultOpts := []cli.Option{ cli.Flag(&force, "force", 'f', "Force for sub1"), cli.Flag(&something, "something", 's', "Something for sub1"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { if something == "" { something = "<empty>" }
extra, ok := cmd.ExtraArgs() if !ok { extra = []string{} }
fmt.Fprintf( cmd.Stdout(), "Hello from sub1, my args were: %v, force was %v, something was %s, extra args: %v", cmd.Args(), force, something, extra, )
return nil }), } opts := slices.Concat(defaultOpts, tt.sub1Options)
return cli.New( "sub1", opts..., ) }
sub2 := func() (*cli.Command, error) { defaultOpts := []cli.Option{ cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintf( cmd.Stdout(), "Hello from sub2, my args were: %v, delete was %v, number was %d", cmd.Args(), deleteMe, number, )
return nil }), cli.Flag(&deleteMe, "delete", 'd', "Delete for sub2"), cli.Flag(&number, "number", 'n', "Number for sub2", cli.FlagDefault(-1)), }
opts := slices.Concat(defaultOpts, tt.sub2Options)
return cli.New( "sub2", opts..., ) }
root, err := cli.New( "root", cli.SubCommands(sub1, sub2), cli.Stdout(stdoutBuf), cli.Stderr(stderrBuf), cli.OverrideArgs(tt.args), )
test.Ok(t, err)
// Execute the command, we should see the sub commands get executed based on what args we provide err = root.Execute(t.Context()) test.WantErr(t, err, tt.wantErr)
if !tt.wantErr { test.Equal(t, stdoutBuf.String(), tt.stdout) test.Equal(t, stderrBuf.String(), tt.stderr) } }) }}
func TestHelp(t *testing.T) { sub1 := func() (*cli.Command, error) { return cli.New( "sub1", cli.Short("Do one thing"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub1")
return nil }), ) }
sub2 := func() (*cli.Command, error) { return cli.New( "sub2", cli.Short("Do another thing"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub2")
return nil }), ) }
sub3 := func() (*cli.Command, error) { return cli.New( "very-long-subcommand", cli.Short("Wow so long"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub3")
return nil }), ) }
tests := []struct { name string // Identifier of the test case options []cli.Option // Options to apply to the command wantErr bool // Whether we want an error }{ { name: "default long", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, wantErr: false, }, { name: "default short", options: []cli.Option{ cli.OverrideArgs([]string{"-h"}), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, wantErr: false, }, { name: "with examples", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Example("Do a thing", "test do thing --now"), cli.Example("Do a different thing", "test do thing --different"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, wantErr: false, }, { name: "with named arguments", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Arg( new(string), "src", "The file to copy", ), // This one is required cli.Arg( new(string), "dest", "Destination to copy to", cli.ArgDefault("default.txt"), ), // This one is optional cli.Arg(new(int), "other", "Something else", cli.ArgDefault(0)), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, wantErr: false, }, { name: "with verbosity count", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Arg( new(string), "src", "The file to copy", ), // This one is required cli.Arg( new(string), "dest", "Destination to copy to", cli.ArgDefault("destination.txt"), ), // This one is optional cli.Flag(new(flag.Count), "verbosity", 'v', "Increase the verbosity level"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, wantErr: false, }, { name: "with full description", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Short("A cool CLI to do things"), cli.Long("A longer, probably multiline description"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, wantErr: false, }, { name: "full description strip whitespace", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Short(" \t\n A cool CLI to do things \n "), cli.Long(" \t\n\n A longer, probably multiline description \t\n\n "), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, wantErr: false, }, { name: "with no description", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, wantErr: false, }, { name: "with subcommands", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Short("A cool CLI to do things"), cli.Long("A longer, probably multiline description"), cli.SubCommands(sub1, sub2), }, wantErr: false, }, { name: "subcommands different lengths", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Short("A cool CLI to do things"), cli.Long("A longer, probably multiline description"), cli.SubCommands(sub1, sub2, sub3), }, wantErr: false, }, { name: "with subcommands and flags", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Short("A cool CLI to do things"), cli.Long("A longer, probably multiline description"), cli.SubCommands(sub1, sub2), cli.Flag(new(bool), "delete", 'd', "Delete something"), cli.Flag(new(int), "count", flag.NoShortHand, "Count something", cli.FlagDefault(-1)), cli.Flag(new([]string), "things", flag.NoShortHand, "Names of things"), cli.Flag( new([]string), "more", flag.NoShortHand, "Names of things with a default", cli.FlagDefault([]string{"one", "two"}), ), }, wantErr: false, }, { name: "flag with env var", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Short("A test command"), cli.Flag(new(bool), "force", 'f', "Force something", cli.Env[bool]("MYTOOL_FORCE")), cli.Run(func(_ context.Context, _ *cli.Command) error { return nil }), }, wantErr: false, }, { name: "flags with multiple env vars", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Short("A test command"), cli.Flag(new(bool), "force", 'f', "Force something", cli.Env[bool]("MYTOOL_FORCE")), cli.Flag(new(int), "count", 'c', "A much longer usage description here", cli.Env[int]("MYTOOL_COUNT")), cli.Flag(new(string), "name", 'n', "Name", cli.Env[string]("MYTOOL_NAME")), cli.Run(func(_ context.Context, _ *cli.Command) error { return nil }), }, wantErr: false, }, { name: "flag with default and env var", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), cli.Short("A test command"), cli.Flag(new(int), "count", 'c', "Count things", cli.FlagDefault(5), cli.Env[int]("MYTOOL_COUNT")), cli.Flag(new(string), "name", 'n', "A name", cli.Env[string]("MYTOOL_NAME")), cli.Run(func(_ context.Context, _ *cli.Command) error { return nil }), }, wantErr: false, }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { snap := snapshot.New( t, snapshot.Update(*update), snapshot.WithFormatter(snapshot.TextFormatter()), )
stderr := &bytes.Buffer{} stdout := &bytes.Buffer{}
// Test specific overrides to the options in the table options := []cli.Option{ cli.Stdout(stdout), cli.Stderr(stderr), }
cmd, err := cli.New("test", slices.Concat(options, tt.options)...)
test.Ok(t, err)
err = cmd.Execute(t.Context()) test.WantErr(t, err, tt.wantErr)
if *debug { fmt.Printf("DEBUG\n_____\n\n%s\n", stderr.String()) }
// Should have no output to stdout test.Equal(t, stdout.String(), "")
// --help output should be as per the snapshot snap.Snap(stderr.String()) }) }}
func TestVersion(t *testing.T) { sub1 := func() (*cli.Command, error) { return cli.New( "sub1", cli.Short("Do one thing"), // No version set on sub1 cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub1")
return nil }), ) }
sub2 := func() (*cli.Command, error) { return cli.New( "sub2", cli.Short("Do another thing"), cli.Version("sub2 version text"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub2")
return nil }), ) }
tests := []struct { name string // Name of the test case stderr string // Expected output to stderr options []cli.Option // Options to apply to the test command wantErr bool // Whether we want an error or not }{ { name: "default long", options: []cli.Option{ cli.OverrideArgs([]string{"--version"}), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: dev\n", wantErr: false, }, { name: "default short", options: []cli.Option{ cli.OverrideArgs([]string{"-V"}), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: dev\n", wantErr: false, }, { name: "with version", options: []cli.Option{ cli.OverrideArgs([]string{"--version"}), cli.Version("v3.1.7"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: v3.1.7\n", wantErr: false, }, { name: "with commit", options: []cli.Option{ cli.OverrideArgs([]string{"--version"}), cli.Commit("eedb45b"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: dev\nCommit: eedb45b\n", wantErr: false, }, { name: "with build date", options: []cli.Option{ cli.OverrideArgs([]string{"--version"}), cli.BuildDate("2024-04-11T02:23:42Z"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: dev\nBuildDate: 2024-04-11T02:23:42Z\n", wantErr: false, }, { name: "with version and commit", options: []cli.Option{ cli.OverrideArgs([]string{"--version"}), cli.Version("v8.17.6"), cli.Commit("b9aaafd"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: v8.17.6\nCommit: b9aaafd\n", wantErr: false, }, { name: "with all", options: []cli.Option{ cli.OverrideArgs([]string{"--version"}), cli.Version("v8.17.6"), cli.Commit("b9aaafd"), cli.BuildDate("2024-08-17T10:37:30Z"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: v8.17.6\nCommit: b9aaafd\nBuildDate: 2024-08-17T10:37:30Z\n", wantErr: false, }, { name: "call on subcommand with no version", options: []cli.Option{ cli.OverrideArgs([]string{"sub1", "--version"}), cli.Version("v8.17.6"), cli.Commit("b9aaafd"), cli.BuildDate("2024-08-17T10:37:30Z"), cli.SubCommands(sub1, sub2), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, // Should show the root commands version info stderr: "sub1\n\nVersion: v8.17.6\nCommit: b9aaafd\nBuildDate: 2024-08-17T10:37:30Z\n", wantErr: false, }, { name: "call on subcommand with version", options: []cli.Option{ cli.OverrideArgs([]string{"sub2", "--version"}), cli.Version("v8.17.6"), cli.Commit("b9aaafd"), cli.BuildDate("2024-08-17T10:37:30Z"), cli.SubCommands(sub1, sub2), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), }, // Should show sub2's version text stderr: "sub2\n\nVersion: sub2 version text\n", wantErr: false, }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { stderr := &bytes.Buffer{} stdout := &bytes.Buffer{}
// Test specific overrides to the options in the table options := []cli.Option{ cli.Stdout(stdout), cli.Stderr(stderr), cli.NoColour(true), }
cmd, err := cli.New("version-test", slices.Concat(options, tt.options)...) test.Ok(t, err)
err = cmd.Execute(t.Context()) test.WantErr(t, err, tt.wantErr)
// Should have no output to stdout test.Equal(t, stdout.String(), "")
// --version output should be as desired test.Equal(t, stderr.String(), tt.stderr) }) }}
func TestOptionValidation(t *testing.T) { tests := []struct { name string // Name of the test case errMsg string // Expected error message options []cli.Option // Options to apply to the command }{ { name: "nil stdin", options: []cli.Option{cli.Stdin(nil)}, errMsg: "cannot set Stdin to nil", }, { name: "nil stdout", options: []cli.Option{cli.Stdout(nil)}, errMsg: "cannot set Stdout to nil", }, { name: "nil stderr", options: []cli.Option{cli.Stderr(nil)}, errMsg: "cannot set Stderr to nil", }, { name: "nil all three", options: []cli.Option{cli.Stdout(nil), cli.Stderr(nil), cli.Stdin(nil)}, errMsg: "cannot set Stdout to nil\ncannot set Stderr to nil\ncannot set Stdin to nil", }, { name: "nil override args", options: []cli.Option{cli.OverrideArgs(nil)}, errMsg: "cannot set Args to nil", }, { name: "nil Run", options: []cli.Option{cli.Run(nil)}, errMsg: "cannot set Run to nil", }, { name: "flag already exists", options: []cli.Option{ cli.Flag(new(int), "count", 'c', "Count something"), cli.Flag(new(int), "count", 'c', "Count something (again)"), }, errMsg: `flag "count" already defined`, }, { name: "flag short already exists", options: []cli.Option{ cli.Flag(new(int), "count", 'c', "Count something"), cli.Flag(new(string), "config", 'c', "Path to config file"), }, errMsg: `could not add flag "config" to command "test": shorthand "c" already in use for flag "count"`, }, { name: "example comment empty", options: []cli.Option{cli.Example("", "command here")}, errMsg: "example comment cannot be empty", }, { name: "example command empty", options: []cli.Option{cli.Example("comment here", "")}, errMsg: "example command cannot be empty", }, { name: "empty short description", options: []cli.Option{cli.Short("")}, errMsg: "cannot set command short description to an empty string", }, { name: "empty long description", options: []cli.Option{cli.Long("")}, errMsg: "cannot set command long description to an empty string", }, { name: "empty arg name", options: []cli.Option{cli.Arg(new(string), "", "empty required arg")}, errMsg: `invalid arg name "": must not be empty`, }, { name: "arg name contains whitespace", options: []cli.Option{cli.Arg(new(string), "a space", "some space things")}, errMsg: `invalid arg name "a space": cannot contain whitespace`, }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := cli.New("test", tt.options...) test.Err(t, err) // Invalid option should have triggered an error test.Equal(t, err.Error(), tt.errMsg) // Error message was not as expected }) }}
func TestDuplicateSubCommands(t *testing.T) { sub1 := func() (*cli.Command, error) { return cli.New( "sub1", cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), ) }
sub2 := func() (*cli.Command, error) { return cli.New( "sub2", cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), ) }
sub1Again := func() (*cli.Command, error) { return cli.New( "sub1", cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), ) }
_, err := cli.New( "root", cli.SubCommands(sub1, sub2, sub1Again), // This should cause the error )
test.Err(t, err)
if err != nil { test.Equal(t, err.Error(), `subcommand "sub1" already defined`) }}
func TestCommandNoRunNoSub(t *testing.T) { _, err := cli.New( "root", cli.OverrideArgs([]string{}), // Run function missing and no subcommand ) test.Err(t, err)}
func TestExecuteNilCommand(t *testing.T) { var cmd *cli.Command
err := cmd.Execute(t.Context()) test.Err(t, err)
if err != nil { test.Equal(t, err.Error(), "Execute called on a nil Command") }}
func TestEnvFlag(t *testing.T) { tests := []struct { name string setup func(t *testing.T) stdout string errMsg string args []string wantErr bool }{ { name: "bool flag set via env var", setup: func(t *testing.T) { t.Setenv("MYTOOL_FORCE", "true") }, stdout: "force: true\n", args: []string{}, wantErr: false, }, { name: "CLI bool flag overrides env var", setup: func(t *testing.T) { t.Setenv("MYTOOL_FORCE", "true") }, stdout: "force: false\n", args: []string{"--force=false"}, wantErr: false, }, { name: "env var not set leaves flag at default", stdout: "force: false\n", args: []string{}, wantErr: false, }, { name: "invalid env var value propagates error through Execute", setup: func(t *testing.T) { t.Setenv("MYTOOL_FORCE", "notabool") }, args: []string{}, wantErr: true, errMsg: `failed to parse command flags: could not set flag from env: env var MYTOOL_FORCE: parse error: flag "force" received invalid value "notabool" (expected bool): strconv.ParseBool: parsing "notabool": invalid syntax`, }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.setup != nil { tt.setup(t) }
var force bool
stdout := &bytes.Buffer{}
cmd, err := cli.New("test", cli.Stdout(stdout), cli.Flag(&force, "force", flag.NoShortHand, "Force something", cli.Env[bool]("MYTOOL_FORCE")), cli.OverrideArgs(tt.args), cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintf(cmd.Stdout(), "force: %v\n", force)
return nil }), ) test.Ok(t, err)
err = cmd.Execute(t.Context()) test.WantErr(t, err, tt.wantErr)
if tt.wantErr && tt.errMsg != "" { test.Equal(t, err.Error(), tt.errMsg) }
if !tt.wantErr { test.Equal(t, stdout.String(), tt.stdout) } }) }}
// The order in which we apply options shouldn't matter, this test// shuffles the order of the options and asserts the Command we get// out behaves the same as a baseline.func TestCommandOptionOrder(t *testing.T) { baseLineStdout := &bytes.Buffer{} baseLineStderr := &bytes.Buffer{}
shuffleStdout := &bytes.Buffer{} shuffleStderr := &bytes.Buffer{}
var ( f bool count int )
sub := func() (*cli.Command, error) { return cli.New( "sub", cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub")
return nil }), ) }
options := []cli.Option{ cli.OverrideArgs([]string{"some", "args", "here", "--flag", "--count", "10"}), cli.Short("Short description"), cli.Long("Long description"), cli.Example("Do a thing", "demo run something --flag"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintf(cmd.Stdout(), "args: %v, flag: %v, count: %v\n", cmd.Args(), f, count)
return nil }), cli.Arg(new(string), "first", "First arg"), // It just needs *some* args cli.Arg(new(string), "second", "Second arg"), cli.Arg(new(string), "third", "Third arg"), cli.Version("v1.2.3"), cli.SubCommands(sub), cli.Flag(&f, "flag", 'f', "Set a bool flag"), cli.Flag(&count, "count", 'c', "Count a thing"), }
baseLineOptions := slices.Concat( options, []cli.Option{ cli.Stderr(baseLineStderr), // Set output streams specific to the baseline cli.Stdout(baseLineStdout), }, )
baseline, err := cli.New("baseline", baseLineOptions...) test.Ok(t, err)
err = baseline.Execute(t.Context()) test.Ok(t, err)
// Make sure the baseline is behaving as expected test.Equal(t, baseLineStdout.String(), "args: [some args here], flag: true, count: 10\n") test.Equal(t, baseLineStderr.String(), "")
// Shuffley shuffle, 100 permutations should do it for range 100 { shuffled := shuffle(options)
// Set output streams specific to the shuffled command shuffleOptions := slices.Concat( shuffled, []cli.Option{ cli.Stderr(shuffleStderr), cli.Stdout(shuffleStdout), }, )
// Make a Command with the randomly ordered options shuffle, err := cli.New("shuffle", shuffleOptions...) test.Ok(t, err)
// The two commands should behave equivalently err = shuffle.Execute(t.Context()) test.Ok(t, err)
test.Equal(t, shuffleStdout.String(), baseLineStdout.String()) test.Equal(t, shuffleStderr.String(), baseLineStderr.String())
// Clear the buffers for the next loop shuffleStderr.Reset() shuffleStdout.Reset() }}
func TestExecuteSubCommandRepeatRun(t *testing.T) { // Repeated invocations of the same Command must be able to be repeatedly executed leaf := func() (*cli.Command, error) { var force bool
return cli.New( "leaf", cli.Short("A leaf subcommand"), cli.Flag(&force, "force", 'f', "Force something"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), ) }
mid := func() (*cli.Command, error) { return cli.New( "mid", cli.Short("An intermediate subcommand"), cli.SubCommands(leaf), ) }
cmd, err := cli.New( "root", cli.SubCommands(mid), cli.OverrideArgs([]string{"mid", "leaf", "--force"}), cli.Stderr(io.Discard), cli.Stdout(io.Discard), ) test.Ok(t, err)
for range 3 { test.Ok(t, cmd.Execute(t.Context())) }}
func BenchmarkExecuteHelp(b *testing.B) { sub1 := func() (*cli.Command, error) { return cli.New( "sub1", cli.Short("Do one thing"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub1")
return nil }), ) }
sub2 := func() (*cli.Command, error) { return cli.New( "sub2", cli.Short("Do another thing"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub2")
return nil }), ) }
sub3 := func() (*cli.Command, error) { return cli.New( "very-long-subcommand", cli.Short("Wow so long"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub3")
return nil }), ) }
cmd, err := cli.New( "bench-help", cli.Short("A helpful benchmark command"), cli.Long("Much longer text..."), cli.Example("Do a thing", "bench-help very-long-subcommand --flag"), cli.SubCommands(sub1, sub2, sub3), cli.OverrideArgs([]string{"--help"}), cli.Stderr(io.Discard), cli.Stdout(io.Discard), ) test.Ok(b, err)
for b.Loop() { err := cmd.Execute(b.Context()) if err != nil { b.Fatalf("Execute returned an error: %v", err) } }}
// BenchmarkNew measures calling New to build a small CLI.func BenchmarkNew(b *testing.B) { for b.Loop() { _, err := cli.New( "benchy", cli.Short("A typical CLI to benchmark calling cli.New"), cli.Version("dev"), cli.Commit("dfdddaf"), cli.Example("An example", "bench --help"), cli.Flag(new(bool), "force", 'f', "Force something"), cli.Flag(new(string), "name", 'n', "The name of something"), cli.Flag(new(int), "count", 'c', "Count something", cli.FlagDefault(1)), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), ) if err != nil { b.Fatal(err) } }}
// BenchmarkExecute measures the performance of actually invoking a CLI.func BenchmarkExecute(b *testing.B) { var ( force bool name string count int first string )
cmd, err := cli.New( "bench-execute", cli.Short("A runnable command to benchmark Execute"), cli.Flag(&force, "force", 'f', "Force something"), cli.Flag(&name, "name", 'n', "Name of the thing"), cli.Flag(&count, "count", 'c', "Count of things", cli.FlagDefault(1)), cli.Arg(&first, "first", "A positional argument"), cli.OverrideArgs([]string{"positional", "--force", "--name", "bench", "--count", "10"}), cli.Stderr(io.Discard), cli.Stdout(io.Discard), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), ) test.Ok(b, err)
for b.Loop() { err := cmd.Execute(b.Context()) if err != nil { b.Fatalf("Execute returned an error: %v", err) } }}
// BenchmarkExecuteSubcommand measures executing a subcommand.func BenchmarkExecuteSubcommand(b *testing.B) { leaf := func() (*cli.Command, error) { var ( force bool name string )
return cli.New( "leaf", cli.Short("A leaf subcommand"), cli.Flag(&force, "force", 'f', "Force something"), cli.Flag(&name, "name", 'n', "Name of the thing"), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), ) }
mid := func() (*cli.Command, error) { return cli.New( "mid", cli.Short("An intermediate subcommand"), cli.SubCommands(leaf), ) }
cmd, err := cli.New( "bench-sub", cli.Short("A command with nested subcommands"), cli.SubCommands(mid), cli.OverrideArgs([]string{"mid", "leaf", "--force", "--name", "bench"}), cli.Stderr(io.Discard), cli.Stdout(io.Discard), ) test.Ok(b, err)
for b.Loop() { err := cmd.Execute(b.Context()) if err != nil { b.Fatalf("Execute returned an error: %v", err) } }}
// Benchmarks calling the --version flag.func BenchmarkVersion(b *testing.B) { cmd, err := cli.New( "bench-version", cli.Version("v1.2.3"), cli.Commit("f0d129cbda5cac5f57ffe091481bfb35fc6d5ee4"), cli.BuildDate("2025-02-16T07:36:04Z"), cli.OverrideArgs([]string{"--version"}), cli.Stderr(io.Discard), cli.Stdout(io.Discard), cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), ) test.Ok(b, err)
for b.Loop() { err := cmd.Execute(b.Context()) if err != nil { b.Fatalf("Execute returned an error: %v", err) } }}
// shuffle returns a randomly ordered copy of items.func shuffle[T any](items []T) []T { shuffled := slices.Clone(items)
rand.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
return shuffled}