From 3c197fd84d33d7c5fbdb49dda81b22e8dae8b388 Mon Sep 17 00:00:00 2001 From: Tom Fleet Date: Thu, 9 Apr 2026 11:55:30 +0100 Subject: [PATCH] Allow associating env vars with flags --- README.md | 29 + command.go | 27 +- command_test.go | 112 ++++ examples/subcommands/cli.go | 2 +- internal/flag/config.go | 3 + internal/flag/flag.go | 41 +- internal/flag/set.go | 61 ++- internal/flag/set_test.go | 500 ++++++++++++++++++ internal/flag/value.go | 9 + option.go | 39 +- .../snapshots/TestHelp/default_long.snap.txt | 4 +- .../snapshots/TestHelp/default_short.snap.txt | 4 +- .../flag_with_default_and_env_var.snap.txt | 10 + .../TestHelp/flag_with_env_var.snap.txt | 9 + .../flags_with_multiple_env_vars.snap.txt | 11 + ...full_description_strip_whitespace.snap.txt | 4 +- .../subcommands_different_lengths.snap.txt | 4 +- .../snapshots/TestHelp/with_examples.snap.txt | 4 +- .../TestHelp/with_full_description.snap.txt | 4 +- .../TestHelp/with_named_arguments.snap.txt | 4 +- .../TestHelp/with_no_description.snap.txt | 4 +- .../TestHelp/with_subcommands.snap.txt | 4 +- .../with_subcommands_and_flags.snap.txt | 12 +- .../TestHelp/with_verbosity_count.snap.txt | 6 +- 24 files changed, 847 insertions(+), 60 deletions(-) create mode 100644 testdata/snapshots/TestHelp/flag_with_default_and_env_var.snap.txt create mode 100644 testdata/snapshots/TestHelp/flag_with_env_var.snap.txt create mode 100644 testdata/snapshots/TestHelp/flags_with_multiple_env_vars.snap.txt diff --git a/README.md b/README.md index 6b4b32b..ffc8b9e 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,35 @@ func buildCmd() (*cli.Command, error) { > [!TIP] > Default values can be provided with the [cli.FlagDefault](https://pkg.go.dev/go.followtheprocess.codes/cli#FlagDefault) Option +> [!TIP] +> Flags can also be set via environment variables using the [cli.Env](https://pkg.go.dev/go.followtheprocess.codes/cli#Env) option. +> Environment variables are always overridden by explicit CLI flags. + +```go +var force bool +cli.Flag(&force, "force", 'f', "Force deletion", cli.Env[bool]("MYTOOL_FORCE")) +``` + +> [!NOTE] +> `cli.Env` requires an explicit type parameter because Go cannot infer it from the string argument alone. +> The compiler enforces that the type matches the flag — `cli.Env[string](...)` on a `bool` flag is a compile error. + +When `MYTOOL_FORCE=true` is set in the environment, `--force` is implied. Passing `--force=false` on the command line always wins. + +Slice flags accept comma-separated env var values: + +```sh +MYTOOL_ITEMS='one,two,three' mytool +``` + +The env var name is also shown in `--help` output: + +``` +Options: + + -f --force bool Force deletion (env: $MYTOOL_FORCE) +``` + The types are all inferred automatically! No more `BoolSliceVarP` ✨ The types you can use for flags currently are: diff --git a/command.go b/command.go index 54be8a4..b84cbc6 100644 --- a/command.go +++ b/command.go @@ -667,24 +667,25 @@ func writeFlags(cmd *Command, s *strings.Builder) error { shorthand = "N/A" } - line := fmt.Sprintf( - " %s\t--%s\t%s\t%s\t", style.Bold.Text(shorthand), + defaultStr := "" + if fl.Default() != "" { + defaultStr = "[default: " + fl.Default() + "]" + } + + envStr := "" + if fl.EnvVar() != "" { + envStr = "(env: $" + fl.EnvVar() + ")" + } + + line := fmt.Sprintf(" %s\t--%s\t%s\t%s\t%s\t%s", + style.Bold.Text(shorthand), style.Bold.Text(name), fl.Type(), fl.Usage(), + defaultStr, + envStr, ) - if fl.Default() != "" { - line = fmt.Sprintf( - " %s\t--%s\t%s\t%s\t[default: %s]", - style.Bold.Text(shorthand), - style.Bold.Text(name), - fl.Type(), - fl.Usage(), - fl.Default(), - ) - } - fmt.Fprintln(tw, line) } diff --git a/command_test.go b/command_test.go index ed77874..6d8bf75 100644 --- a/command_test.go +++ b/command_test.go @@ -506,6 +506,39 @@ func TestHelp(t *testing.T) { }, 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 { @@ -852,6 +885,85 @@ func TestExecuteNilCommand(t *testing.T) { } } +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. diff --git a/examples/subcommands/cli.go b/examples/subcommands/cli.go index 3c7c647..3927c7a 100644 --- a/examples/subcommands/cli.go +++ b/examples/subcommands/cli.go @@ -88,7 +88,7 @@ func buildDoCommand() (*cli.Command, error) { cli.Example("Do it for a specific duration", "demo do something --duration 1m30s"), cli.Version("do version"), cli.Arg(&thing, "thing", "Thing to do"), - cli.Flag(&options.count, "count", 'c', "Number of times to do the thing", cli.FlagDefault(1)), + cli.Flag(&options.count, "count", 'c', "Number of times to do the thing", cli.FlagDefault(1), cli.Env[int]("DEMO_COUNT")), cli.Flag(&options.fast, "fast", 'f', "Do the thing quickly"), cli.Flag(&options.verbosity, "verbosity", 'v', "Increase the verbosity level"), cli.Flag(&options.duration, "duration", 'd', "Do the thing for a specific duration", cli.FlagDefault(1*time.Second)), diff --git a/internal/flag/config.go b/internal/flag/config.go index 1a94e88..07c929d 100644 --- a/internal/flag/config.go +++ b/internal/flag/config.go @@ -6,4 +6,7 @@ import "go.followtheprocess.codes/cli/flag" type Config[T flag.Flaggable] struct { // DefaultValue holds the intended default value of the flag. DefaultValue T + // EnvVar is the name of an environment variable that may set this flag's value + // if the flag is not explicitly provided on the command line. + EnvVar string } diff --git a/internal/flag/flag.go b/internal/flag/flag.go index 5cd0abd..9e0b12b 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -23,10 +23,11 @@ var _ Value = Flag[string]{} // This will fail if we violate our Value interface // Flag represents a single command line flag. type Flag[T flag.Flaggable] struct { - value *T // The actual stored value - name string // The name of the flag as appears on the command line, e.g. "force" for a --force flag - usage string // One line description of the flag, e.g. "Force deletion without confirmation" - short rune // Optional shorthand version of the flag, e.g. "f" for a -f flag + value *T // The actual stored value + name string // The name of the flag as appears on the command line, e.g. "force" for a --force flag + usage string // one line description of the flag, e.g. "Force deletion without confirmation" + envVar string // Name of an environment variable that may set this flag's value if the flag is not explicitly provided on the command line + short rune // Optional shorthand version of the flag, e.g. "f" for a -f flag } // New constructs and returns a new [Flag]. @@ -49,10 +50,11 @@ func New[T flag.Flaggable](p *T, name string, short rune, usage string, config C *p = config.DefaultValue flag := Flag[T]{ - value: p, - name: name, - usage: usage, - short: short, + value: p, + name: name, + usage: usage, + short: short, + envVar: config.EnvVar, } return flag, nil @@ -89,6 +91,29 @@ func (f Flag[T]) Default() string { return f.String() } +// EnvVar returns the name of the environment variable associated with this flag, +// or an empty string if none was configured. +func (f Flag[T]) EnvVar() string { + return f.envVar +} + +// IsSlice reports whether the flag holds a slice value that accumulates repeated +// calls to Set. Returns false for []byte and net.IP, which are parsed atomically. +func (f Flag[T]) IsSlice() bool { + if f.value == nil { + return false + } + + switch any(*f.value).(type) { + case []int, []int8, []int16, []int32, []int64, + []uint, []uint16, []uint32, []uint64, + []float32, []float64, []string: + return true + default: + return false + } +} + // NoArgValue returns a string representation of value the flag should hold // when it is given no arguments on the command line. For example a boolean flag // --delete, when passed without arguments implies --delete true. diff --git a/internal/flag/set.go b/internal/flag/set.go index 1f2325d..a3e3fcd 100644 --- a/internal/flag/set.go +++ b/internal/flag/set.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "iter" + "os" "slices" "strings" @@ -13,10 +14,11 @@ import ( // Set is a set of command line flags. type Set struct { - flags map[string]Value // The actual stored flags, can lookup by name - shorthands map[rune]Value // The flags by shorthand - args []string // Arguments minus flags or flag values - extra []string // Arguments after "--" was hit + flags map[string]Value // The actual stored flags, can lookup by name + shorthands map[rune]Value // The flags by shorthand + envVars map[string]string // flag name → env var name + args []string // Arguments minus flags or flag values + extra []string // Arguments after "--" was hit } // NewSet builds and returns a new set of flags. @@ -24,6 +26,7 @@ func NewSet() *Set { return &Set{ flags: make(map[string]Value), shorthands: make(map[rune]Value), + envVars: make(map[string]string), } } @@ -50,6 +53,10 @@ func AddToSet[T flag.Flaggable](set *Set, f Flag[T]) error { set.flags[name] = f + if f.envVar != "" { + set.envVars[name] = f.envVar + } + // Only add the shorthand if it wasn't opted out of if short != flag.NoShortHand { set.shorthands[short] = f @@ -143,11 +150,17 @@ func (s *Set) ExtraArgs() []string { } // Parse parses flags and their values from the command line. -func (s *Set) Parse(args []string) (err error) { +func (s *Set) Parse(args []string) error { + var err error + if s == nil { return errors.New("Parse called on a nil set") } + if err = s.applyEnvVars(); err != nil { + return fmt.Errorf("could not set flag from env: %w", err) + } + for len(args) > 0 { arg := args[0] // The argument we're currently inspecting args = args[1:] // Remainder @@ -202,6 +215,44 @@ func (s *Set) All() iter.Seq2[string, Value] { } } +// applyEnvVars looks up each configured environment variable and applies its value +// to the corresponding flag. It is called at the start of Parse so that CLI args +// parsed afterward naturally override these values. +// +// Slice flags accept comma-separated values e.g. MYTOOL_ITEMS='one,two,three'. +// Empty and unset variables are ignored. +func (s *Set) applyEnvVars() error { + for name, envName := range s.envVars { + val, ok := os.LookupEnv(envName) + if !ok || val == "" { + continue + } + + f, ok := s.flags[name] + if !ok { + return fmt.Errorf("flag %q does not exist", name) + } + + if f.IsSlice() { + for item := range strings.SplitSeq(val, ",") { + if item = strings.TrimSpace(item); item != "" { + if err := f.Set(item); err != nil { + return fmt.Errorf("env var %s: %w", envName, err) + } + } + } + + continue + } + + if err := f.Set(val); err != nil { + return fmt.Errorf("env var %s: %w", envName, err) + } + } + + return nil +} + // parseLongFlag parses a single long flag e.g. --delete. It is passed // the possible long flag and the rest of the argument list and returns // the remaining arguments after it's done parsing to the caller. diff --git a/internal/flag/set_test.go b/internal/flag/set_test.go index b9def28..8aae707 100644 --- a/internal/flag/set_test.go +++ b/internal/flag/set_test.go @@ -3,6 +3,7 @@ package flag_test import ( "iter" "maps" + "net" "slices" "testing" "time" @@ -864,6 +865,505 @@ func TestParse(t *testing.T) { args: []string{"-ccc"}, wantErr: false, }, + { + name: "env var applied when flag not set on CLI", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_DELETE", "true") + + var val bool + + f, err := flag.New(&val, "delete", 'd', "Delete something", flag.Config[bool]{EnvVar: "MYTOOL_DELETE"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("delete") + test.True(t, exists) + test.Equal(t, f.String(), format.True) + }, + args: []string{}, + wantErr: false, + }, + { + name: "CLI long flag overrides env var", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_DELETE", "true") + + var val bool + + f, err := flag.New(&val, "delete", 'd', "Delete something", flag.Config[bool]{EnvVar: "MYTOOL_DELETE"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("delete") + test.True(t, exists) + test.Equal(t, f.String(), "false") + }, + args: []string{"--delete=false"}, + wantErr: false, + }, + { + name: "CLI short flag overrides env var", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_DELETE", "true") + + var val bool + + f, err := flag.New(&val, "delete", 'd', "Delete something", flag.Config[bool]{EnvVar: "MYTOOL_DELETE"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("delete") + test.True(t, exists) + test.Equal(t, f.String(), "false") + }, + args: []string{"-d=false"}, + wantErr: false, + }, + { + name: "invalid env var value returns error", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_COUNT", "notanumber") + + var val int + + f, err := flag.New(&val, "count", 'c', "Count", flag.Config[int]{EnvVar: "MYTOOL_COUNT"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + args: []string{}, + wantErr: true, + errMsg: `could not set flag from env: env var MYTOOL_COUNT: parse error: flag "count" received invalid value "notanumber" (expected int): strconv.ParseInt: parsing "notanumber": invalid syntax`, + }, + { + name: "env var not set leaves flag at default", + newSet: func(t *testing.T) *flag.Set { + // MYTOOL_DELETE is deliberately NOT set in the environment + var val bool + + f, err := flag.New(&val, "delete", 'd', "Delete something", flag.Config[bool]{EnvVar: "MYTOOL_DELETE"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("delete") + test.True(t, exists) + test.Equal(t, f.String(), "false") + }, + args: []string{}, + wantErr: false, + }, + { + name: "env var applied when terminator present", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_DELETE", "true") + + var val bool + + f, err := flag.New(&val, "delete", 'd', "Delete something", flag.Config[bool]{EnvVar: "MYTOOL_DELETE"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("delete") + test.True(t, exists) + test.Equal(t, f.String(), format.True) + test.EqualFunc(t, set.ExtraArgs(), []string{"extra"}, slices.Equal) + }, + args: []string{"--", "extra"}, + wantErr: false, + }, + { + name: "flag without env var configured is unaffected by environment", + newSet: func(t *testing.T) *flag.Set { + // Env var is set in the OS but NOT wired to this flag + t.Setenv("MYTOOL_DELETE", "true") + + var val bool + + f, err := flag.New(&val, "delete", 'd', "Delete something", flag.Config[bool]{}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("delete") + test.True(t, exists) + test.Equal(t, f.String(), "false") + }, + args: []string{}, + wantErr: false, + }, + { + name: "slice flag set via comma-separated env var", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_ITEMS", "one,two,three") + + var val []string + + f, err := flag.New(&val, "item", 'i', "Add item", flag.Config[[]string]{EnvVar: "MYTOOL_ITEMS"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("item") + test.True(t, exists) + test.Equal(t, f.String(), `["one", "two", "three"]`) + }, + args: []string{}, + wantErr: false, + }, + { + name: "count env var and CLI flags both accumulate", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_VERBOSITY", "2") + + var val publicflag.Count + + f, err := flag.New(&val, "verbosity", 'v', "Increase verbosity", flag.Config[publicflag.Count]{EnvVar: "MYTOOL_VERBOSITY"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("verbosity") + test.True(t, exists) + // Env var contributes 2, CLI contributes 1 more — total 3 + test.Equal(t, f.String(), "3") + }, + args: []string{"--verbosity"}, + wantErr: false, + }, + { + name: "slice env var and CLI flags both accumulate", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_ITEMS", "one,two") + + var val []string + + f, err := flag.New(&val, "item", 'i', "Add item", flag.Config[[]string]{EnvVar: "MYTOOL_ITEMS"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("item") + test.True(t, exists) + test.Equal(t, f.String(), `["one", "two", "three"]`) + }, + args: []string{"--item", "three"}, + wantErr: false, + }, + { + name: "net.IP flag set via env var", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_HOST", "192.168.1.1") + + var val net.IP + + f, err := flag.New(&val, "host", 'h', "Host IP address", flag.Config[net.IP]{EnvVar: "MYTOOL_HOST"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("host") + test.True(t, exists) + test.Equal(t, f.String(), "192.168.1.1") + }, + args: []string{}, + wantErr: false, + }, + { + name: "net.IP flag env var is overridden by CLI", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_HOST", "192.168.1.1") + + var val net.IP + + f, err := flag.New(&val, "host", 'h', "Host IP address", flag.Config[net.IP]{EnvVar: "MYTOOL_HOST"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("host") + test.True(t, exists) + test.Equal(t, f.String(), "10.0.0.1") + }, + args: []string{"--host", "10.0.0.1"}, + wantErr: false, + }, + { + name: "invalid net.IP env var returns error", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_HOST", "notanip") + + var val net.IP + + f, err := flag.New(&val, "host", 'h', "Host IP address", flag.Config[net.IP]{EnvVar: "MYTOOL_HOST"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + args: []string{}, + wantErr: true, + errMsg: `could not set flag from env: env var MYTOOL_HOST: parse error: flag "host" received invalid value "notanip" (expected net.IP): invalid IP address`, + }, + { + name: "time.Duration flag set via env var", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_TIMEOUT", "30s") + + var val time.Duration + + f, err := flag.New(&val, "timeout", 't', "Request timeout", flag.Config[time.Duration]{EnvVar: "MYTOOL_TIMEOUT"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("timeout") + test.True(t, exists) + test.Equal(t, f.String(), "30s") + }, + args: []string{}, + wantErr: false, + }, + { + name: "time.Duration flag env var is overridden by CLI", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_TIMEOUT", "30s") + + var val time.Duration + + f, err := flag.New(&val, "timeout", 't', "Request timeout", flag.Config[time.Duration]{EnvVar: "MYTOOL_TIMEOUT"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("timeout") + test.True(t, exists) + test.Equal(t, f.String(), "1m0s") + }, + args: []string{"--timeout", "1m"}, + wantErr: false, + }, + { + name: "invalid time.Duration env var returns error", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_TIMEOUT", "notaduration") + + var val time.Duration + + f, err := flag.New(&val, "timeout", 't', "Request timeout", flag.Config[time.Duration]{EnvVar: "MYTOOL_TIMEOUT"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + args: []string{}, + wantErr: true, + errMsg: `could not set flag from env: env var MYTOOL_TIMEOUT: parse error: flag "timeout" received invalid value "notaduration" (expected time.Duration): time: invalid duration "notaduration"`, + }, + { + name: "time.Time flag set via env var", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_SINCE", "2024-08-17T10:37:30Z") + + var val time.Time + + f, err := flag.New( + &val, + "since", + publicflag.NoShortHand, + "Start time (RFC3339)", + flag.Config[time.Time]{EnvVar: "MYTOOL_SINCE"}, + ) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("since") + test.True(t, exists) + test.Equal(t, f.String(), "2024-08-17T10:37:30Z") + }, + args: []string{}, + wantErr: false, + }, + { + name: "invalid time.Time env var returns error", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_SINCE", "not-a-time") + + var val time.Time + + f, err := flag.New( + &val, + "since", + publicflag.NoShortHand, + "Start time (RFC3339)", + flag.Config[time.Time]{EnvVar: "MYTOOL_SINCE"}, + ) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + args: []string{}, + wantErr: true, + errMsg: `could not set flag from env: env var MYTOOL_SINCE: parse error: flag "since" received invalid value "not-a-time" (expected time.Time): parsing time "not-a-time" as "2006-01-02T15:04:05Z07:00": cannot parse "not-a-time" as "2006"`, + }, + { + name: "[]int flag set via comma-separated env var", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_PORTS", "8080,8081,8082") + + var val []int + + f, err := flag.New(&val, "port", 'p', "Port numbers", flag.Config[[]int]{EnvVar: "MYTOOL_PORTS"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("port") + test.True(t, exists) + test.Equal(t, f.String(), "[8080, 8081, 8082]") + }, + args: []string{}, + wantErr: false, + }, + { + name: "invalid value in []int comma-separated env var returns error", + newSet: func(t *testing.T) *flag.Set { + t.Setenv("MYTOOL_PORTS", "8080,notaport,8082") + + var val []int + + f, err := flag.New(&val, "port", 'p', "Port numbers", flag.Config[[]int]{EnvVar: "MYTOOL_PORTS"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + args: []string{}, + wantErr: true, + errMsg: `could not set flag from env: env var MYTOOL_PORTS: parse error: flag "port" (type []int) cannot append element "notaport": strconv.ParseInt: parsing "notaport": invalid syntax`, + }, + { + name: "bytes flag env var is parsed atomically, not split on comma", + newSet: func(t *testing.T) *flag.Set { + // A hex string that contains a comma should be parsed as-is, not split + t.Setenv("MYTOOL_DATA", "deadbeef") + + var val []byte + + f, err := flag.New(&val, "data", 'd', "Raw bytes", flag.Config[[]byte]{EnvVar: "MYTOOL_DATA"}) + test.Ok(t, err) + + set := flag.NewSet() + err = flag.AddToSet(set, f) + test.Ok(t, err) + + return set + }, + test: func(t *testing.T, set *flag.Set) { + f, exists := set.Get("data") + test.True(t, exists) + test.Equal(t, f.String(), "deadbeef") + }, + args: []string{}, + wantErr: false, + }, } for _, tt := range tests { diff --git a/internal/flag/value.go b/internal/flag/value.go index 97c3255..97067b5 100644 --- a/internal/flag/value.go +++ b/internal/flag/value.go @@ -20,6 +20,10 @@ type Value interface { // an empty string is returned. Default() string + // EnvVar returns the name of the environment variable associated with this flag, + // or an empty string if none was configured. + EnvVar() string + // NoArgValue returns astring representation of the value of the flag when no // args are passed (e.g --bool implies --bool true). NoArgValue() string @@ -27,6 +31,11 @@ type Value interface { // Type returns the string representation of the flag type e.g. "bool". Type() string + // IsSlice reports whether the flag holds a slice value that accumulates + // repeated calls to Set (e.g. []string, []int). Note that []byte and net.IP + // are NOT slice flags in this sense — they are parsed atomically. + IsSlice() bool + // Set sets the stored value of a flag by parsing the string "str". Set(str string) error } diff --git a/option.go b/option.go index 829b83b..174d337 100644 --- a/option.go +++ b/option.go @@ -487,18 +487,45 @@ func ArgDefault[T arg.Argable](value T) ArgOption[T] { return argOption[T](f) } +// Env is a [FlagOption] that associates an environment variable with a flag. +// +// When the flag is not explicitly set on the command line, CLI checks the named +// environment variable. If it is set and non-empty, its value is parsed using the +// same mechanism as command-line values. If it is not set or is empty, the flag +// retains its default value. +// +// For scalar flags, command-line values always take priority over environment variables. +// For slice and count flags, the environment variable provides a base value and any +// CLI flags accumulate on top. +// +// Slice flags accept comma-separated values: +// +// MYTOOL_ITEMS='one,two,three' +// +// var noApprove bool +// cli.Flag(&noApprove, "no-approve", cli.NoShortHand, "Skip approval", cli.Env[bool]("MYTOOL_NO_APPROVE")) +func Env[T flag.Flaggable](name string) FlagOption[T] { + return flagOption[T](func(cfg *internalflag.Config[T]) error { + if name == "" { + return errors.New("env var name cannot be empty") + } + + cfg.EnvVar = name + + return nil + }) +} + // FlagDefault is a [cli.FlagOption] that sets the default value for command line flag. // // By default, a flag's default value is the zero value for its type. But using this // option, you may set a non-zero default value that the flag should inherit if not // provided on the command line. func FlagDefault[T flag.Flaggable](value T) FlagOption[T] { - f := func(cfg *internalflag.Config[T]) error { + return flagOption[T](func(cfg *internalflag.Config[T]) error { cfg.DefaultValue = value return nil - } - - return flagOption[T](f) + }) } // anyDuplicates checks the list of commands for ones with duplicate names, if a duplicate @@ -546,11 +573,11 @@ type FlagOption[T flag.Flaggable] interface { apply(cfg *internalflag.Config[T]) error } -// option is a function adapter implementing the Option interface, analogous +// flagOption is a function adapter implementing the [FlagOption] interface, analogous // to http.HandlerFunc. type flagOption[T flag.Flaggable] func(cfg *internalflag.Config[T]) error -// apply implements the Option interface for option. +// apply implements [FlagOption] for flagOption. // //nolint:unused // This is a false positive, this has to be here func (f flagOption[T]) apply(cfg *internalflag.Config[T]) error { diff --git a/testdata/snapshots/TestHelp/default_long.snap.txt b/testdata/snapshots/TestHelp/default_long.snap.txt index 9f70c6f..980a3db 100644 --- a/testdata/snapshots/TestHelp/default_long.snap.txt +++ b/testdata/snapshots/TestHelp/default_long.snap.txt @@ -4,5 +4,5 @@ Usage: test [OPTIONS] ARGS... Options: - -h --help bool Show help for test - -V --version bool Show version info for test + -h --help bool Show help for test + -V --version bool Show version info for test diff --git a/testdata/snapshots/TestHelp/default_short.snap.txt b/testdata/snapshots/TestHelp/default_short.snap.txt index 9f70c6f..980a3db 100644 --- a/testdata/snapshots/TestHelp/default_short.snap.txt +++ b/testdata/snapshots/TestHelp/default_short.snap.txt @@ -4,5 +4,5 @@ Usage: test [OPTIONS] ARGS... Options: - -h --help bool Show help for test - -V --version bool Show version info for test + -h --help bool Show help for test + -V --version bool Show version info for test diff --git a/testdata/snapshots/TestHelp/flag_with_default_and_env_var.snap.txt b/testdata/snapshots/TestHelp/flag_with_default_and_env_var.snap.txt new file mode 100644 index 0000000..0adbf68 --- /dev/null +++ b/testdata/snapshots/TestHelp/flag_with_default_and_env_var.snap.txt @@ -0,0 +1,10 @@ +A test command + +Usage: test [OPTIONS] ARGS... + +Options: + + -c --count int Count things [default: 5] (env: $MYTOOL_COUNT) + -h --help bool Show help for test + -n --name string A name (env: $MYTOOL_NAME) + -V --version bool Show version info for test diff --git a/testdata/snapshots/TestHelp/flag_with_env_var.snap.txt b/testdata/snapshots/TestHelp/flag_with_env_var.snap.txt new file mode 100644 index 0000000..ccacf9c --- /dev/null +++ b/testdata/snapshots/TestHelp/flag_with_env_var.snap.txt @@ -0,0 +1,9 @@ +A test command + +Usage: test [OPTIONS] ARGS... + +Options: + + -f --force bool Force something (env: $MYTOOL_FORCE) + -h --help bool Show help for test + -V --version bool Show version info for test diff --git a/testdata/snapshots/TestHelp/flags_with_multiple_env_vars.snap.txt b/testdata/snapshots/TestHelp/flags_with_multiple_env_vars.snap.txt new file mode 100644 index 0000000..5ddf4c5 --- /dev/null +++ b/testdata/snapshots/TestHelp/flags_with_multiple_env_vars.snap.txt @@ -0,0 +1,11 @@ +A test command + +Usage: test [OPTIONS] ARGS... + +Options: + + -c --count int A much longer usage description here (env: $MYTOOL_COUNT) + -f --force bool Force something (env: $MYTOOL_FORCE) + -h --help bool Show help for test + -n --name string Name (env: $MYTOOL_NAME) + -V --version bool Show version info for test diff --git a/testdata/snapshots/TestHelp/full_description_strip_whitespace.snap.txt b/testdata/snapshots/TestHelp/full_description_strip_whitespace.snap.txt index 538d77c..fd11646 100644 --- a/testdata/snapshots/TestHelp/full_description_strip_whitespace.snap.txt +++ b/testdata/snapshots/TestHelp/full_description_strip_whitespace.snap.txt @@ -6,5 +6,5 @@ Usage: test [OPTIONS] ARGS... Options: - -h --help bool Show help for test - -V --version bool Show version info for test + -h --help bool Show help for test + -V --version bool Show version info for test diff --git a/testdata/snapshots/TestHelp/subcommands_different_lengths.snap.txt b/testdata/snapshots/TestHelp/subcommands_different_lengths.snap.txt index ae9c28b..1ca0b2a 100644 --- a/testdata/snapshots/TestHelp/subcommands_different_lengths.snap.txt +++ b/testdata/snapshots/TestHelp/subcommands_different_lengths.snap.txt @@ -12,7 +12,7 @@ Commands: Options: - -h --help bool Show help for test - -V --version bool Show version info for test + -h --help bool Show help for test + -V --version bool Show version info for test Use "test [command] -h/--help" for more information about a command. diff --git a/testdata/snapshots/TestHelp/with_examples.snap.txt b/testdata/snapshots/TestHelp/with_examples.snap.txt index 105700a..07efe52 100644 --- a/testdata/snapshots/TestHelp/with_examples.snap.txt +++ b/testdata/snapshots/TestHelp/with_examples.snap.txt @@ -12,5 +12,5 @@ Examples: Options: - -h --help bool Show help for test - -V --version bool Show version info for test + -h --help bool Show help for test + -V --version bool Show version info for test diff --git a/testdata/snapshots/TestHelp/with_full_description.snap.txt b/testdata/snapshots/TestHelp/with_full_description.snap.txt index 538d77c..fd11646 100644 --- a/testdata/snapshots/TestHelp/with_full_description.snap.txt +++ b/testdata/snapshots/TestHelp/with_full_description.snap.txt @@ -6,5 +6,5 @@ Usage: test [OPTIONS] ARGS... Options: - -h --help bool Show help for test - -V --version bool Show version info for test + -h --help bool Show help for test + -V --version bool Show version info for test diff --git a/testdata/snapshots/TestHelp/with_named_arguments.snap.txt b/testdata/snapshots/TestHelp/with_named_arguments.snap.txt index f86cdb2..4ff021b 100644 --- a/testdata/snapshots/TestHelp/with_named_arguments.snap.txt +++ b/testdata/snapshots/TestHelp/with_named_arguments.snap.txt @@ -10,5 +10,5 @@ Arguments: Options: - -h --help bool Show help for test - -V --version bool Show version info for test + -h --help bool Show help for test + -V --version bool Show version info for test diff --git a/testdata/snapshots/TestHelp/with_no_description.snap.txt b/testdata/snapshots/TestHelp/with_no_description.snap.txt index 9f70c6f..980a3db 100644 --- a/testdata/snapshots/TestHelp/with_no_description.snap.txt +++ b/testdata/snapshots/TestHelp/with_no_description.snap.txt @@ -4,5 +4,5 @@ Usage: test [OPTIONS] ARGS... Options: - -h --help bool Show help for test - -V --version bool Show version info for test + -h --help bool Show help for test + -V --version bool Show version info for test diff --git a/testdata/snapshots/TestHelp/with_subcommands.snap.txt b/testdata/snapshots/TestHelp/with_subcommands.snap.txt index d85eed9..d1add92 100644 --- a/testdata/snapshots/TestHelp/with_subcommands.snap.txt +++ b/testdata/snapshots/TestHelp/with_subcommands.snap.txt @@ -11,7 +11,7 @@ Commands: Options: - -h --help bool Show help for test - -V --version bool Show version info for test + -h --help bool Show help for test + -V --version bool Show version info for test Use "test [command] -h/--help" for more information about a command. diff --git a/testdata/snapshots/TestHelp/with_subcommands_and_flags.snap.txt b/testdata/snapshots/TestHelp/with_subcommands_and_flags.snap.txt index 81abb6b..8c08ca1 100644 --- a/testdata/snapshots/TestHelp/with_subcommands_and_flags.snap.txt +++ b/testdata/snapshots/TestHelp/with_subcommands_and_flags.snap.txt @@ -11,11 +11,11 @@ Commands: Options: - N/A --count int Count something [default: -1] - -d --delete bool Delete something - -h --help bool Show help for test - N/A --more []string Names of things with a default [default: ["one", "two"]] - N/A --things []string Names of things - -V --version bool Show version info for test + N/A --count int Count something [default: -1] + -d --delete bool Delete something + -h --help bool Show help for test + N/A --more []string Names of things with a default [default: ["one", "two"]] + N/A --things []string Names of things + -V --version bool Show version info for test Use "test [command] -h/--help" for more information about a command. diff --git a/testdata/snapshots/TestHelp/with_verbosity_count.snap.txt b/testdata/snapshots/TestHelp/with_verbosity_count.snap.txt index 1d047db..51623e7 100644 --- a/testdata/snapshots/TestHelp/with_verbosity_count.snap.txt +++ b/testdata/snapshots/TestHelp/with_verbosity_count.snap.txt @@ -9,6 +9,6 @@ Arguments: Options: - -h --help bool Show help for test - -v --verbosity count Increase the verbosity level - -V --version bool Show version info for test + -h --help bool Show help for test + -v --verbosity count Increase the verbosity level + -V --version bool Show version info for test