diff --git a/README.md b/README.md index 389f006..35b07c6 100644 --- a/README.md +++ b/README.md @@ -522,10 +522,10 @@ Tags can be in two forms: Both can coexist with standard Tag parsing. | Tag | Description | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `cmd:""` | If present, struct is a command. | | `arg:""` | If present, field is an argument. Required by default. | -| `env:"X"` | Specify envar to use for default value. | +| `env:"X,Y,..."` | Specify envars to use for default value. The envs are resolved in the declared order. The first value found is used. | | `name:"X"` | Long name, for overriding field name. | | `help:"X"` | Help text. | | `type:"X"` | Specify [named types](#custom-named-decoders) to use. | diff --git a/build.go b/build.go index 0a23fd0..e23c115 100644 --- a/build.go +++ b/build.go @@ -138,8 +138,10 @@ MAIN: name = tag.Prefix + name } - if tag.Env != "" { - tag.Env = tag.EnvPrefix + tag.Env + if len(tag.Envs) != 0 { + for i := range tag.Envs { + tag.Envs[i] = tag.EnvPrefix + tag.Envs[i] + } } // Nested structs are either commands or args, unless they implement the Mapper interface. @@ -304,7 +306,7 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv Value: value, Short: tag.Short, PlaceHolder: tag.PlaceHolder, - Env: tag.Env, + Envs: tag.Envs, Group: buildGroupForKey(k, tag.Group), Xor: tag.Xor, Hidden: tag.Hidden, diff --git a/context.go b/context.go index de9e408..0ec10f4 100644 --- a/context.go +++ b/context.go @@ -165,16 +165,16 @@ func (c *Context) Validate() error { // nolint: gocyclo err := Visit(c.Model, func(node Visitable, next Next) error { switch node := node.(type) { case *Value: - _, ok := os.LookupEnv(node.Tag.Env) - if node.Enum != "" && (!node.Required || node.HasDefault || (node.Tag.Env != "" && ok)) { + ok := atLeastOneEnvSet(node.Tag.Envs) + if node.Enum != "" && (!node.Required || node.HasDefault || (len(node.Tag.Envs) != 0 && ok)) { if err := checkEnum(node, node.Target); err != nil { return err } } case *Flag: - _, ok := os.LookupEnv(node.Tag.Env) - if node.Enum != "" && (!node.Required || node.HasDefault || (node.Tag.Env != "" && ok)) { + ok := atLeastOneEnvSet(node.Tag.Envs) + if node.Enum != "" && (!node.Required || node.HasDefault || (len(node.Tag.Envs) != 0 && ok)) { if err := checkEnum(node.Value, node.Target); err != nil { return err } @@ -890,9 +890,8 @@ func checkMissingPositionals(positional int, values []*Value) error { for ; positional < len(values); positional++ { arg := values[positional] // TODO(aat): Fix hardcoding of these env checks all over the place :\ - if arg.Tag.Env != "" { - _, ok := os.LookupEnv(arg.Tag.Env) - if ok { + if len(arg.Tag.Envs) != 0 { + if atLeastOneEnvSet(arg.Tag.Envs) { continue } } @@ -997,3 +996,12 @@ func isValidatable(v reflect.Value) validatable { } return nil } + +func atLeastOneEnvSet(envs []string) bool { + for _, env := range envs { + if _, ok := os.LookupEnv(env); ok { + return true + } + } + return false +} diff --git a/help.go b/help.go index b265121..4fb7700 100644 --- a/help.go +++ b/help.go @@ -86,10 +86,10 @@ type HelpValueFormatter func(value *Value) string // DefaultHelpValueFormatter is the default HelpValueFormatter. func DefaultHelpValueFormatter(value *Value) string { - if value.Tag.Env == "" || HasInterpolatedVar(value.OrigHelp, "env") { + if len(value.Tag.Envs) == 0 || HasInterpolatedVar(value.OrigHelp, "env") { return value.Help } - suffix := "($" + value.Tag.Env + ")" + suffix := "(" + formatEnvs(value.Tag.Envs) + ")" switch { case strings.HasSuffix(value.Help, "."): return value.Help[:len(value.Help)-1] + " " + suffix + "." @@ -567,3 +567,12 @@ func TreeIndenter(prefix string) string { } return "|" + strings.Repeat(" ", defaultIndent) + prefix } + +func formatEnvs(envs []string) string { + formatted := make([]string, len(envs)) + for i := range envs { + formatted[i] = "$" + envs[i] + } + + return strings.Join(formatted, ", ") +} diff --git a/help_test.go b/help_test.go index f93084b..2c94660 100644 --- a/help_test.go +++ b/help_test.go @@ -472,6 +472,18 @@ func TestEnvarAutoHelp(t *testing.T) { assert.Contains(t, w.String(), "A flag ($FLAG).") } +func TestMultipleEnvarAutoHelp(t *testing.T) { + var cli struct { + Flag string `env:"FLAG1,FLAG2" help:"A flag."` + } + w := &strings.Builder{} + p := mustNew(t, &cli, kong.Writers(w, w), kong.Exit(func(int) {})) + _, err := p.Parse([]string{"--help"}) + assert.NoError(t, err) + assert.Contains(t, w.String(), "A flag ($FLAG1, $FLAG2).") +} + +//nolint:dupl // false positive func TestEnvarAutoHelpWithEnvPrefix(t *testing.T) { type Anonymous struct { Flag string `env:"FLAG" help:"A flag."` @@ -488,6 +500,24 @@ func TestEnvarAutoHelpWithEnvPrefix(t *testing.T) { assert.Contains(t, w.String(), "A different flag.") } +//nolint:dupl // false positive +func TestMultipleEnvarAutoHelpWithEnvPrefix(t *testing.T) { + type Anonymous struct { + Flag string `env:"FLAG1,FLAG2" help:"A flag."` + Other string `help:"A different flag."` + } + var cli struct { + Anonymous `envprefix:"ANON_"` + } + w := &strings.Builder{} + p := mustNew(t, &cli, kong.Writers(w, w), kong.Exit(func(int) {})) + _, err := p.Parse([]string{"--help"}) + assert.NoError(t, err) + assert.Contains(t, w.String(), "A flag ($ANON_FLAG1, $ANON_FLAG2).") + assert.Contains(t, w.String(), "A different flag.") +} + +//nolint:dupl // false positive func TestCustomValueFormatter(t *testing.T) { var cli struct { Flag string `env:"FLAG" help:"A flag."` @@ -505,6 +535,24 @@ func TestCustomValueFormatter(t *testing.T) { assert.Contains(t, w.String(), "A flag.") } +//nolint:dupl // false positive +func TestMultipleCustomValueFormatter(t *testing.T) { + var cli struct { + Flag string `env:"FLAG1,FLAG2" help:"A flag."` + } + w := &strings.Builder{} + p := mustNew(t, &cli, + kong.Writers(w, w), + kong.Exit(func(int) {}), + kong.ValueFormatter(func(value *kong.Value) string { + return value.Help + }), + ) + _, err := p.Parse([]string{"--help"}) + assert.NoError(t, err) + assert.Contains(t, w.String(), "A flag.") +} + func TestAutoGroup(t *testing.T) { var cli struct { GroupedAString string `help:"A string flag grouped in A."` diff --git a/kong.go b/kong.go index 70e8906..255633f 100644 --- a/kong.go +++ b/kong.go @@ -227,11 +227,16 @@ func (k *Kong) interpolateValue(value *Value, vars Vars) (err error) { return fmt.Errorf("enum value for %s: %s", value.Summary(), err) } if value.Flag != nil { - if value.Flag.Env, err = interpolate(value.Flag.Env, vars, nil); err != nil { - return fmt.Errorf("env value for %s: %s", value.Summary(), err) + for i, env := range value.Flag.Envs { + if value.Flag.Envs[i], err = interpolate(env, vars, nil); err != nil { + return fmt.Errorf("env value for %s: %s", value.Summary(), err) + } + } + value.Tag.Envs = value.Flag.Envs + updatedVars["env"] = "" + if len(value.Flag.Envs) != 0 { + updatedVars["env"] = value.Flag.Envs[0] } - value.Tag.Env = value.Flag.Env - updatedVars["env"] = value.Flag.Env } value.Help, err = interpolate(value.Help, vars, updatedVars) if err != nil { diff --git a/kong_test.go b/kong_test.go index 2dd7cd3..72c73cf 100644 --- a/kong_test.go +++ b/kong_test.go @@ -665,7 +665,7 @@ func TestInterpolationIntoModel(t *testing.T) { assert.Equal(t, map[string]bool{"a": true, "b": true, "c": true, "d": true}, flag.EnumMap()) assert.Equal(t, []string{"a", "b", "c", "d"}, flag.EnumSlice()) assert.Equal(t, "One of a,b", flag2.Help) - assert.Equal(t, "SAVE_THE_QUEEN", flag3.Env) + assert.Equal(t, []string{"SAVE_THE_QUEEN"}, flag3.Envs) assert.Equal(t, "God SAVE_THE_QUEEN", flag3.Help) } diff --git a/model.go b/model.go index 1428965..793cf97 100644 --- a/model.go +++ b/model.go @@ -366,14 +366,17 @@ func (v *Value) ApplyDefault() error { // Does not include resolvers. func (v *Value) Reset() error { v.Target.Set(reflect.Zero(v.Target.Type())) - if v.Tag.Env != "" { - envar := os.Getenv(v.Tag.Env) - if envar != "" { - err := v.Parse(ScanFromTokens(Token{Type: FlagValueToken, Value: envar}), v.Target) - if err != nil { - return fmt.Errorf("%s (from envar %s=%q)", err, v.Tag.Env, envar) + if len(v.Tag.Envs) != 0 { + for _, env := range v.Tag.Envs { + envar := os.Getenv(env) + // Parse the first non-empty ENV in the list + if envar != "" { + err := v.Parse(ScanFromTokens(Token{Type: FlagValueToken, Value: envar}), v.Target) + if err != nil { + return fmt.Errorf("%s (from envar %s=%q)", err, env, envar) + } + return nil } - return nil } } if v.HasDefault { @@ -393,7 +396,7 @@ type Flag struct { Group *Group // Logical grouping when displaying. May also be used by configuration loaders to group options logically. Xor []string PlaceHolder string - Env string + Envs []string Short rune Hidden bool Negated bool diff --git a/options.go b/options.go index 8633f68..8d2893c 100644 --- a/options.go +++ b/options.go @@ -451,21 +451,21 @@ func siftStrings(ss []string, filter func(s string) bool) []string { // --some.value -> PREFIX_SOME_VALUE func DefaultEnvars(prefix string) Option { processFlag := func(flag *Flag) { - switch env := flag.Env; { + switch env := flag.Envs; { case flag.Name == "help": return - case env == "-": - flag.Env = "" + case len(env) == 1 && env[0] == "-": + flag.Envs = nil return - case env != "": + case len(env) > 0: return } replacer := strings.NewReplacer("-", "_", ".", "_") names := append([]string{prefix}, camelCase(replacer.Replace(flag.Name))...) names = siftStrings(names, func(s string) bool { return !(s == "_" || strings.TrimSpace(s) == "") }) name := strings.ToUpper(strings.Join(names, "_")) - flag.Env = name - flag.Value.Tag.Env = name + flag.Envs = append(flag.Envs, name) + flag.Value.Tag.Envs = append(flag.Value.Tag.Envs, name) } var processNode func(node *Node) diff --git a/resolver_test.go b/resolver_test.go index 3c4ded0..c1684b6 100644 --- a/resolver_test.go +++ b/resolver_test.go @@ -58,6 +58,26 @@ func TestEnvarsFlagBasic(t *testing.T) { assert.Equal(t, "foo", cli.Interp) } +func TestEnvarsFlagMultiple(t *testing.T) { + var cli struct { + FirstENVPresent string `env:"KONG_TEST1_1,KONG_TEST1_2"` + SecondENVPresent string `env:"KONG_TEST2_1,KONG_TEST2_2"` + } + parser, unsetEnvs := newEnvParser(t, &cli, + envMap{ + "KONG_TEST1_1": "value1.1", + "KONG_TEST1_2": "value1.2", + "KONG_TEST2_2": "value2.2", + }, + ) + defer unsetEnvs() + + _, err := parser.Parse([]string{}) + assert.NoError(t, err) + assert.Equal(t, "value1.1", cli.FirstENVPresent) + assert.Equal(t, "value2.2", cli.SecondENVPresent) +} + func TestEnvarsFlagOverride(t *testing.T) { var cli struct { Flag string `env:"KONG_FLAG"` @@ -97,6 +117,23 @@ func TestEnvarsEnvPrefix(t *testing.T) { assert.Equal(t, []int{1, 2, 3}, cli.Slice) } +func TestEnvarsEnvPrefixMultiple(t *testing.T) { + type Anonymous struct { + Slice1 []int `env:"NUMBERS1_1,NUMBERS1_2"` + Slice2 []int `env:"NUMBERS2_1,NUMBERS2_2"` + } + var cli struct { + Anonymous `envprefix:"KONG_"` + } + parser, restoreEnv := newEnvParser(t, &cli, envMap{"KONG_NUMBERS1_1": "1,2,3", "KONG_NUMBERS2_2": "5,6,7"}) + defer restoreEnv() + + _, err := parser.Parse([]string{}) + assert.NoError(t, err) + assert.Equal(t, []int{1, 2, 3}, cli.Slice1) + assert.Equal(t, []int{5, 6, 7}, cli.Slice2) +} + func TestEnvarsNestedEnvPrefix(t *testing.T) { type NestedAnonymous struct { String string `env:"STRING"` diff --git a/tag.go b/tag.go index cac4074..f99059b 100644 --- a/tag.go +++ b/tag.go @@ -24,7 +24,7 @@ type Tag struct { Default string Format string PlaceHolder string - Env string + Envs []string Short rune Hidden bool Sep rune @@ -234,7 +234,9 @@ func hydrateTag(t *Tag, typ reflect.Type) error { // nolint: gocyclo t.Help = t.Get("help") t.Type = t.Get("type") t.TypeName = typeName - t.Env = t.Get("env") + for _, env := range t.GetAll("env") { + t.Envs = append(t.Envs, strings.FieldsFunc(env, tagSplitFn)...) + } t.Short, err = t.GetRune("short") if err != nil && t.Get("short") != "" { return fmt.Errorf("invalid short flag name %q: %s", t.Get("short"), err)