diff --git a/.golangci.yaml b/.golangci.yaml index b43367f..45fb197 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -145,6 +145,7 @@ linters: tagalign: order: - flag + - arg - env - default - secret diff --git a/CHANGELOG.md b/CHANGELOG.md index f495b2d..3b3e674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-05-20 + +### Added + +- Added positional CLI argument binding via the `arg` struct tag. + ## [0.4.0] - 2026-05-20 ### Added @@ -38,7 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed integer parsing to use the correct bit size for signed and unsigned integer fields. -[Unreleased]: https://github.com/tilebox/structconf/compare/v0.4.0...HEAD +[Unreleased]: https://github.com/tilebox/structconf/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/tilebox/structconf/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/tilebox/structconf/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/tilebox/structconf/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/tilebox/structconf/compare/v0.1.0...v0.2.0 diff --git a/README.md b/README.md index 6eb360e..b94fabb 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ go get github.com/tilebox/structconf ## Features -- Load configuration from CLI flags, environment variables, `.toml` config files or specified default values - or from all of them at once - - Order of precedence: CLI flags, config files, environment variables, default values +- Load configuration from CLI flags, CLI arguments, environment variables, `.toml` config files or specified default values - or from all of them at once + - Order of precedence: CLI flags/arguments, config files, environment variables, default values - By only defining a struct containing all the fields you want to configure - Structs can be nested within other structs - Supported data types: `string`, `[]string`, `int`, `int8-64`, `uint`, `uint8-64`, `bool`, `float`, `time.Duration` - Customize certain fields by adding tags to the struct fields - - Using the tags `flag`, `env`, `default`, `secret`, `toml`, `validate`, `global`, `help` + - Using the tags `flag`, `arg`, `env`, `default`, `secret`, `toml`, `validate`, `global`, `help` - Includes input validation using [go-playground/validator](https://github.com/go-playground/validator) - Help message generated out of the box - Composable command binding helpers for subcommand CLIs via `BindCommand` / `NewCommand` @@ -277,7 +277,31 @@ func main() { } ``` -`BindCommand` and `NewCommand` currently support flags, env vars and default values. `WithLoadConfigFlag` is currently only supported by `Load` / `MustLoad`. +`BindCommand` and `NewCommand` currently support flags, arguments, env vars and default values. `WithLoadConfigFlag` is currently only supported by `Load` / `MustLoad`. + +### Bind positional arguments + +Use the `arg` tag to bind positional CLI arguments by zero-based index. Argument-bound fields are not also exposed as flags; define either `arg` or `flag` for a field. + +```go +type AppConfig struct { + SomeFlag string `flag:"some-flag"` + SecondArg string `arg:"1" default:"world"` + OtherFlag bool `flag:"other-flag"` + FirstArg int `arg:"0"` +} + +cfg := &AppConfig{} +err := structconf.LoadArgs(cfg, "app", []string{"app", "--some-flag", "hello", "42", "Tilebox", "--other-flag"}) +if err != nil { + panic(err) +} + +// cfg.FirstArg == 42 +// cfg.SecondArg == "Tilebox" +``` + +Arguments use the same fallback sources as flags: if a positional argument is omitted, structconf checks TOML config files, then environment variables, then the `default` tag. Argument indexes must start at `0` and be contiguous; duplicate indexes or gaps are reported as errors. Structconf-bound arguments implement `ArgumentMetadata` for tools that need argument name, type name, and usage text. ### Parse custom arg slices @@ -321,7 +345,7 @@ By default, field names are converted to flags, env vars and toml properties usi - For toml properties, field names are converted to kebab-case, e.g. `MyFieldName` becomes `my-field-name` - Common initialisms are respected, e.g. `MyServerURL` becomes `--my-server-url` or `MY_SERVER_URL` -You can override these default rules at any point by using the `flag`, `env` and `toml` tags. +You can override these default rules at any point by using the `flag`, `arg`, `env` and `toml` tags. ```go type AppConfig struct { diff --git a/config.go b/config.go index 4b6badd..82546a3 100644 --- a/config.go +++ b/config.go @@ -14,12 +14,20 @@ import ( type StructReflector interface { Flags() []cli.Flag + Arguments() []cli.Argument Apply(*cli.Command) } +type ArgumentMetadata interface { + Name() string + TypeName() string + UsageText() string +} + type structReflector struct { - foundFlags []cli.Flag // flags found in the struct - applyFuncs []func(*cli.Command) // functions to call after flags are parsed, to apply values to the struct + foundFlags []cli.Flag // flags found in the struct + foundArguments []*configArgument // arguments found in the struct + applyFuncs []func(*cli.Command) // functions to call after flags are parsed, to apply values to the struct tomlSources []cli.MapSource } @@ -28,17 +36,108 @@ func (r *structReflector) Flags() []cli.Flag { return r.foundFlags } +func (r *structReflector) Arguments() []cli.Argument { + slices.SortFunc(r.foundArguments, func(a, b *configArgument) int { + return a.index - b.index + }) + + arguments := make([]cli.Argument, 0, len(r.foundArguments)) + for _, argument := range r.foundArguments { + arguments = append(arguments, argument) + } + + return arguments +} + func (r *structReflector) Apply(command *cli.Command) { for _, applyFunc := range r.applyFuncs { applyFunc(command) } } +type configArgument struct { + index int + argumentName string + usageText string + field reflect.StructField + fieldValue reflect.Value + sources cli.ValueSourceChain + defaultValue string +} + +func (a *configArgument) HasName(name string) bool { + return name == a.argumentName +} + +func (a *configArgument) Parse(args []string) ([]string, error) { + if len(args) > 0 { + if err := setFieldValueFromString(a.field, a.fieldValue, args[0]); err != nil { + return args, fmt.Errorf("invalid value %q for argument %s: %w", args[0], a.argumentName, err) + } + + return args[1:], nil + } + + if value, found := a.sources.Lookup(); found { + if err := setFieldValueFromString(a.field, a.fieldValue, value); err != nil { + return args, fmt.Errorf("failed to parse source value %s for argument %s: %w", value, a.argumentName, err) + } + + return args, nil + } + + if a.defaultValue != "" { + if err := setFieldValueFromString(a.field, a.fieldValue, a.defaultValue); err != nil { + return args, fmt.Errorf("failed to parse default value %s for argument %s: %w", a.defaultValue, a.argumentName, err) + } + } + + return args, nil +} + +func (a *configArgument) Usage() string { + return a.argumentName +} + +func (a *configArgument) Get() any { + return a.fieldValue.Interface() +} + +func (a *configArgument) Name() string { + return a.argumentName +} + +func (a *configArgument) TypeName() string { + return typeName(a.field.Type) +} + +func (a *configArgument) UsageText() string { + return a.usageText +} + +func typeName(valueType reflect.Type) string { + return valueType.String() +} + func (r *structReflector) processField(field reflect.StructField, fieldValue reflect.Value, tags *configFieldTags, parents []*configFieldTags) error { - if tags == nil || tags.flag == "-" { + if tags == nil { + return nil + } + + valueSources := r.valueSources(tags, parents) + + if tags.arg != "" { + return r.processArgument(field, fieldValue, tags, parents, valueSources) + } + + if tags.flag == "-" { return nil } + return r.processFlag(field, fieldValue, tags, parents, valueSources) +} + +func (r *structReflector) valueSources(tags *configFieldTags, parents []*configFieldTags) []cli.ValueSource { valueSources := make([]cli.ValueSource, 0) if tags.toml != "" && tags.toml != "-" && len(r.tomlSources) > 0 { // load from toml file unless explicitly set to "-" @@ -63,6 +162,50 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref valueSources = append(valueSources, cli.EnvVar(envKey)) } + return valueSources +} + +func (r *structReflector) processArgument(field reflect.StructField, fieldValue reflect.Value, tags *configFieldTags, parents []*configFieldTags, valueSources []cli.ValueSource) error { + if tags.hasExplicitFlag && tags.flag != "-" { + return fmt.Errorf("field %s cannot be bound as both argument and flag", field.Name) + } + + index, err := strconv.Atoi(tags.arg) + if err != nil || index < 0 { + return fmt.Errorf("invalid argument index %q for field %s", tags.arg, field.Name) + } + + if err := validateSupportedFieldType(field); err != nil { + return err + } + + argName := tags.toml + if !tags.isGlobal { + argKeys := lo.Map(parents, func(parent *configFieldTags, _ int) string { return parent.flag }) + argKeys = append(argKeys, tags.toml) + argName = strings.Join(argKeys, "-") + } + + for _, argument := range r.foundArguments { + if argument.index == index { + return fmt.Errorf("duplicate argument index: %d", index) + } + } + + r.foundArguments = append(r.foundArguments, &configArgument{ + index: index, + argumentName: argName, + usageText: tags.help, + field: field, + fieldValue: fieldValue, + sources: cli.NewValueSourceChain(valueSources...), + defaultValue: tags.defaultValue, + }) + + return nil +} + +func (r *structReflector) processFlag(field reflect.StructField, fieldValue reflect.Value, tags *configFieldTags, parents []*configFieldTags, valueSources []cli.ValueSource) error { flagName := tags.flag if !tags.isGlobal { flagKeys := lo.Map(parents, func(parent *configFieldTags, _ int) string { return parent.flag }) @@ -452,6 +595,139 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref return nil } +func validateSupportedFieldType(field reflect.StructField) error { + switch field.Type.Kind() { //nolint:exhaustive // we have a default: clause that results in an error + case reflect.String: + return nil + case reflect.Slice: + if field.Type.Elem().Kind() != reflect.String { + return fmt.Errorf("unsupported slice element type %s for field %s", field.Type.Elem().Kind(), field.Name) + } + return nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32: + return nil + case reflect.Int64: + return nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return nil + case reflect.Float32, reflect.Float64: + return nil + case reflect.Bool: + return nil + default: + return fmt.Errorf("unknown field type %s", field.Type.Kind()) + } +} + +func setFieldValueFromString(field reflect.StructField, fieldValue reflect.Value, value string) error { + switch field.Type.Kind() { //nolint:exhaustive // we have a default: clause that results in an error + case reflect.String: + fieldValue.SetString(value) + case reflect.Slice: + if field.Type.Elem().Kind() != reflect.String { + return fmt.Errorf("unsupported slice element type %s for field %s", field.Type.Elem().Kind(), field.Name) + } + + if value == "" { + fieldValue.Set(reflect.Zero(field.Type)) + return nil + } + + fieldValue.Set(reflect.ValueOf(strings.Split(value, ","))) + case reflect.Int: + parsed, err := strconv.ParseInt(value, 10, strconv.IntSize) + if err != nil { + return fmt.Errorf("failed to parse int value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetInt(parsed) + case reflect.Int8: + parsed, err := strconv.ParseInt(value, 10, 8) + if err != nil { + return fmt.Errorf("failed to parse int value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetInt(parsed) + case reflect.Int16: + parsed, err := strconv.ParseInt(value, 10, 16) + if err != nil { + return fmt.Errorf("failed to parse int value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetInt(parsed) + case reflect.Int32: + parsed, err := strconv.ParseInt(value, 10, 32) + if err != nil { + return fmt.Errorf("failed to parse int value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetInt(parsed) + case reflect.Int64: + if _, ok := fieldValue.Interface().(time.Duration); ok { + parsed, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("failed to parse duration %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetInt(int64(parsed)) + return nil + } + + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fmt.Errorf("failed to parse int value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetInt(parsed) + case reflect.Uint: + parsed, err := strconv.ParseUint(value, 10, strconv.IntSize) + if err != nil { + return fmt.Errorf("failed to parse uint value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetUint(parsed) + case reflect.Uint8: + parsed, err := strconv.ParseUint(value, 10, 8) + if err != nil { + return fmt.Errorf("failed to parse uint value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetUint(parsed) + case reflect.Uint16: + parsed, err := strconv.ParseUint(value, 10, 16) + if err != nil { + return fmt.Errorf("failed to parse uint value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetUint(parsed) + case reflect.Uint32: + parsed, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return fmt.Errorf("failed to parse uint value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetUint(parsed) + case reflect.Uint64: + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return fmt.Errorf("failed to parse uint value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetUint(parsed) + case reflect.Float32: + parsed, err := strconv.ParseFloat(value, 32) + if err != nil { + return fmt.Errorf("failed to parse float value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetFloat(parsed) + case reflect.Float64: + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("failed to parse float value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetFloat(parsed) + case reflect.Bool: + parsed, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("failed to parse bool value %s for field %s: %w", value, field.Name, err) + } + fieldValue.SetBool(parsed) + default: + return fmt.Errorf("unknown field type %s", field.Type.Kind()) + } + + return nil +} + func (r *structReflector) recurseStruct(anyStruct any, parents []*configFieldTags) error { structType := reflect.TypeOf(anyStruct) structValues := reflect.ValueOf(anyStruct) @@ -501,11 +777,31 @@ func (r *structReflector) recurseStruct(anyStruct any, parents []*configFieldTag return nil } +func (r *structReflector) validateArguments() error { + if len(r.foundArguments) == 0 { + return nil + } + + seen := make(map[int]bool, len(r.foundArguments)) + for _, argument := range r.foundArguments { + seen[argument.index] = true + } + + for index := range len(r.foundArguments) { + if !seen[index] { + return fmt.Errorf("missing argument binding for index: %d", index) + } + } + + return nil +} + func NewStructConfigurator(anyStruct any, tomlSources []cli.MapSource) (StructReflector, error) { reflector := &structReflector{ - foundFlags: make([]cli.Flag, 0), - applyFuncs: make([]func(*cli.Command), 0), - tomlSources: tomlSources, + foundFlags: make([]cli.Flag, 0), + foundArguments: make([]*configArgument, 0), + applyFuncs: make([]func(*cli.Command), 0), + tomlSources: tomlSources, } err := reflector.recurseStruct(anyStruct, nil) @@ -513,5 +809,9 @@ func NewStructConfigurator(anyStruct any, tomlSources []cli.MapSource) (StructRe return nil, err } + if err := reflector.validateArguments(); err != nil { + return nil, err + } + return reflector, nil } diff --git a/examples/11_arguments/main.go b/examples/11_arguments/main.go new file mode 100644 index 0000000..1fd1ff1 --- /dev/null +++ b/examples/11_arguments/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/tilebox/structconf" +) + +type Config struct { + Greeting string `flag:"greeting" default:"Hello" help:"Greeting to print"` + Name string `arg:"0" env:"NAME" default:"World" help:"Name to greet"` + Count int `arg:"1" default:"1" help:"Number of times to print the greeting"` + Loud bool `flag:"loud" help:"Print in uppercase"` +} + +// usage: +// ./arguments Tilebox 3 --greeting Hi --loud +func main() { + cfg := &Config{} + structconf.MustLoad(cfg, "arguments") + + message := fmt.Sprintf("%s, %s!", cfg.Greeting, cfg.Name) + if cfg.Loud { + message = strings.ToUpper(message) + } + + for range cfg.Count { + fmt.Println(message) + } +} diff --git a/structconf.go b/structconf.go index c34161b..33b263d 100644 --- a/structconf.go +++ b/structconf.go @@ -117,7 +117,7 @@ func MustLoadArgs(configPointer any, programName string, args []string, opts ... // Load loads the given config struct. // // It loads the config from the following sources in the given order: -// 1. command line flags +// 1. command line flags and arguments // 2. config files (if the config struct satisfies the loadConfigFromTOMLFiles interface by embedding LoadTOMLConfig) // 3. environment variables // 4. default values defined in the field tags @@ -225,7 +225,8 @@ func LoadArgs(configPointer any, programName string, args []string, opts ...Opti Usage: cfg.description, EnableShellCompletion: cfg.enableShellCompletion, - Flags: flags, + Flags: flags, + Arguments: config.Arguments(), Action: func(ctx context.Context, cmd *cli.Command) error { config.Apply(cmd) return nil @@ -251,7 +252,7 @@ func LoadArgs(configPointer any, programName string, args []string, opts ...Opti // NewCommand creates a urfave/cli command and binds the given config struct to it. // -// When the command is executed, the config is loaded from flags, env vars and default values, +// When the command is executed, the config is loaded from flags, arguments, env vars and default values, // then the configured validator is run before the optional action is executed. // // The WithLoadConfigFlag option is not currently supported for BindCommand/NewCommand. @@ -271,7 +272,7 @@ func NewCommand(configPointer any, commandName string, action cli.ActionFunc, op // BindCommand binds the given config struct to an existing urfave/cli command. // -// It appends reflected flags to the command and wraps the command's Action so that config +// It appends reflected flags and arguments to the command and wraps the command's Action so that config // loading and the configured validator are run before the existing Action. // // The WithLoadConfigFlag option is not currently supported for BindCommand/NewCommand. @@ -296,6 +297,7 @@ func BindCommand(command *cli.Command, configPointer any, opts ...CommandOption) } command.Flags = flags + command.Arguments = append(command.Arguments, config.Arguments()...) wrappedAction := command.Action command.Action = func(ctx context.Context, cmd *cli.Command) error { diff --git a/structconf_test.go b/structconf_test.go index 0f2d9fe..b6ae331 100644 --- a/structconf_test.go +++ b/structconf_test.go @@ -448,6 +448,170 @@ func Test_LoadArgs(t *testing.T) { assert.Equal(t, "Tilebox", cfg.Name) } +func Test_LoadArgsArgumentBinding(t *testing.T) { + type config struct { + SomeFlag string `flag:"some-flag"` + SecondArg string `arg:"1"` + OtherFlag bool `flag:"other-flag"` + FirstArg int `arg:"0"` + } + + cfg := &config{} + err := LoadArgs(cfg, "my-program", []string{"my-program", "--some-flag", "from-flag", "42", "from-arg", "--other-flag"}) + require.NoError(t, err) + + assert.Equal(t, "from-flag", cfg.SomeFlag) + assert.True(t, cfg.OtherFlag) + assert.Equal(t, 42, cfg.FirstArg) + assert.Equal(t, "from-arg", cfg.SecondArg) +} + +func Test_LoadArgsArgumentFallbacks(t *testing.T) { + type config struct { + Name string `arg:"0" env:"NAME" default:"World" toml:"name"` + Count int `arg:"1" env:"COUNT" default:"1" toml:"count"` + } + + tests := []struct { + name string + cliArgs []string + envVars map[string]string + toml string + wantName string + wantCount int + }{ + { + name: "positional args take precedence", + cliArgs: []string{"my-program", "Tilebox", "3"}, + envVars: map[string]string{"NAME": "from-env", "COUNT": "2"}, + toml: "name = \"from-toml\"\ncount = 4", + wantName: "Tilebox", + wantCount: 3, + }, + { + name: "later args fall back independently", + cliArgs: []string{"my-program", "Tilebox"}, + envVars: map[string]string{"COUNT": "2"}, + wantName: "Tilebox", + wantCount: 2, + }, + { + name: "toml takes precedence over env vars and defaults", + cliArgs: []string{"my-program"}, + envVars: map[string]string{"NAME": "from-env", "COUNT": "2"}, + toml: "name = \"from-toml\"\ncount = 4", + wantName: "from-toml", + wantCount: 4, + }, + { + name: "env vars take precedence over defaults", + cliArgs: []string{"my-program"}, + envVars: map[string]string{"NAME": "from-env", "COUNT": "2"}, + wantName: "from-env", + wantCount: 2, + }, + { + name: "defaults are used if nothing else is set", + cliArgs: []string{"my-program"}, + wantName: "World", + wantCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cliArgs := slices.Clone(tt.cliArgs) + if tt.toml != "" { + configPath := path.Join(t.TempDir(), "test-config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(tt.toml), 0o600)) + cliArgs = append(cliArgs, "--load-config", configPath) + } + + for key, value := range tt.envVars { + t.Setenv(key, value) + } + + cfg := &config{} + err := LoadArgs(cfg, "my-program", cliArgs, WithDefaultLoadConfigFlag()) + require.NoError(t, err) + + assert.Equal(t, tt.wantName, cfg.Name) + assert.Equal(t, tt.wantCount, cfg.Count) + }) + } +} + +func Test_LoadArgsArgumentValidation(t *testing.T) { + type config struct { + Name string `arg:"0" validate:"required"` + } + + cfg := &config{} + err := LoadArgs(cfg, "my-program", []string{"my-program"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Missing required configuration") +} + +func Test_LoadArgsArgumentErrors(t *testing.T) { + tests := []struct { + name string + cfg any + cliArgs []string + wantError string + }{ + { + name: "duplicate indexes disallowed", + cfg: &struct { + First string `arg:"0"` + Second string `arg:"0"` + }{}, + cliArgs: []string{"my-program"}, + wantError: "duplicate argument index: 0", + }, + { + name: "gaps disallowed", + cfg: &struct { + First string `arg:"0"` + Third string `arg:"2"` + }{}, + cliArgs: []string{"my-program"}, + wantError: "missing argument binding for index: 1", + }, + { + name: "invalid index disallowed", + cfg: &struct { + Name string `arg:"not-a-number"` + }{}, + cliArgs: []string{"my-program"}, + wantError: "invalid argument index \"not-a-number\" for field Name", + }, + { + name: "argument and flag binding disallowed", + cfg: &struct { + Name string `flag:"name" arg:"0"` + }{}, + cliArgs: []string{"my-program"}, + wantError: "field Name cannot be bound as both argument and flag", + }, + { + name: "invalid argument value returns parse error", + cfg: &struct { + Count int `arg:"0"` + }{}, + cliArgs: []string{"my-program", "not-a-number"}, + wantError: "invalid value \"not-a-number\" for argument count", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := LoadArgs(tt.cfg, "my-program", tt.cliArgs) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} + func Test_LoadArgsUsesCustomValidator(t *testing.T) { type config struct { Name string `validate:"required"` @@ -525,6 +689,36 @@ func Test_NewCommandSubcommands(t *testing.T) { assert.Equal(t, 0, sumCfg.Right) } +func Test_NewCommandBindsArguments(t *testing.T) { + type config struct { + Name string `arg:"0" help:"Name to greet"` + Loud bool + } + + cfg := &config{} + actionRan := false + + cmd, err := NewCommand(cfg, "greet", func(ctx context.Context, cmd *cli.Command) error { + actionRan = true + return nil + }) + require.NoError(t, err) + + err = cmd.Run(context.Background(), []string{"greet", "Tilebox", "--loud"}) + require.NoError(t, err) + + assert.True(t, actionRan) + assert.Equal(t, "Tilebox", cfg.Name) + assert.True(t, cfg.Loud) + + metadata, ok := cmd.Arguments[0].(ArgumentMetadata) + require.True(t, ok) + assert.Equal(t, "name", metadata.Name()) + assert.Equal(t, "string", metadata.TypeName()) + assert.Equal(t, "Name to greet", metadata.UsageText()) + assert.Equal(t, "name", cmd.Arguments[0].Usage()) +} + func Test_BindCommandValidatesBeforeAction(t *testing.T) { type config struct { Name string `validate:"required"` diff --git a/tags.go b/tags.go index 9921637..f6f4a15 100644 --- a/tags.go +++ b/tags.go @@ -59,10 +59,13 @@ func init() { //nolint:gochecknoinits type configFieldTags struct { flag string + arg string aliases []string isGlobal bool isSecret bool + hasExplicitFlag bool + json string toml string yaml string @@ -76,12 +79,16 @@ type configFieldTags struct { func parseTags(tag *reflect.StructTag) *configFieldTags { isGlobal, _ := strconv.ParseBool(tag.Get("global")) isSecret, _ := strconv.ParseBool(tag.Get("secret")) + flag, hasExplicitFlag := tag.Lookup("flag") parsed := &configFieldTags{ - flag: tag.Get("flag"), + flag: flag, + arg: tag.Get("arg"), isGlobal: isGlobal, isSecret: isSecret, + hasExplicitFlag: hasExplicitFlag, + json: tag.Get("json"), toml: tag.Get("toml"), yaml: tag.Get("yaml"), @@ -110,7 +117,7 @@ func parseTagsWithFieldNameDefault(tag *reflect.StructTag, fieldName string) *co kebab := strcase.ToKebab(fieldName) - if isExported && tags.flag == "" { + if isExported && tags.flag == "" && tags.arg == "" { tags.flag = kebab }