From 270ad17ab9c13f588ad3ecf5a5171586c8bd4167 Mon Sep 17 00:00:00 2001 From: Lukas Bindreiter Date: Wed, 20 May 2026 15:39:54 +0200 Subject: [PATCH] Add string slice support --- AGENTS.md | 48 ++++++++++ CHANGELOG.md | 45 +++++++++ README.md | 31 ++++++- config.go | 23 +++++ examples/10_slices/main.go | 21 +++++ structconf.go | 185 ++++++++++++++++++------------------- structconf_test.go | 95 +++++++++++++++---- toml.go | 17 +++- validate.go | 3 +- 9 files changed, 351 insertions(+), 117 deletions(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 examples/10_slices/main.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..30b9f09 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# AGENTS.md + +## Commands +- Build: `go build ./...` +- Test all: `go test ./...` +- Test single: `go test -run TestName ./...` +- Lint: `golangci-lint run ./...` +- Format touched Go files with `gofmt`/`goimports`; the linter also enables `gci`, `gofmt`, `gofumpt`, and `goimports` formatters. + +## Repo Architecture +- This is a single-package Go module: `github.com/tilebox/structconf`. +- `structconf.go`: Public loading API (`Load`, `LoadArgs`, `MustLoad`, `MustLoadArgs`), functional options, subcommand helpers (`BindCommand`, `NewCommand`), duplicate flag detection, and the two-pass TOML load flow. +- `config.go`: Reflection-based struct walker that turns exported struct fields into `urfave/cli/v3` flags and applies parsed values back into the config struct. +- `tags.go`: Struct tag parsing and default name derivation for flags, env vars, TOML/YAML/JSON keys, aliases, global fields, secrets, defaults, and help text. +- `toml.go`: TOML file loading and `cli.ValueSource` adapters used to feed TOML values into the same precedence chain as env vars/defaults. +- `validate.go`: Default validation using `go-playground/validator/v10`, with user-facing error messages. +- `marshal.go`: Config marshaling helpers (`MarshalAsMap`, `MarshalAsSlogDict`) and secret redaction. +- `examples`: Runnable examples for each major feature; keep examples small and aligned with README snippets. + +## Core Behavior And Contracts +- Source precedence is CLI flags first, then TOML config files, then environment variables, then `default` tags. +- Exported fields get generated names by default: kebab-case flags/TOML/YAML keys, screaming snake env vars, and lower camel JSON names. +- Nested structs compose parent names unless a field is tagged `global:"true"`. +- Fields tagged `flag:"-"` are not configurable; analogous `env:"-"` and `toml:"-"` disable those sources. +- Supported config field types are `string`, `[]string`, signed/unsigned integer widths, floats, `bool`, and `time.Duration`. +- `LoadArgs`/`BindCommand` run validation after values are applied; custom validators replace the default validator. + +## Design Patterns And Paradigms +- Public APIs use functional options (`WithVersion`, `WithDescription`, `WithValidator`, etc.). +- Keep the reflection pipeline centralized in `NewStructConfigurator`, `recurseStruct`, and `processField`; avoid one-off parsing paths that bypass the shared value-source precedence behavior. +- Prefer adding behavior at the source of truth (`config.go`, `tags.go`, or `toml.go`) rather than wrapping public APIs for special cases. +- Return errors with useful context and `%w` when wrapping underlying failures. +- Keep generated CLI help/error text stable where tests assert on it. + +## Code Style +- Use `stretchr/testify` (`require` for setup/fatal checks, `assert` for value comparisons). +- Prefer table-driven tests for multi-source or multi-type behavior. +- Keep imports grouped and formatted by Go tooling. +- Respect struct tag order enforced by lint: `flag`, `env`, `default`, `secret`, `toml`, `json`, `validate`, `global`, `help`. +- Avoid new package-level globals and `init` functions unless there is a strong reason; `tags.go` currently has an intentional `init` for `strcase` initialism configuration. +- Examples may print to stdout; library code should not, except for existing `MustLoad*` error/help handling. + +## Typical Development Flow +1. Make the smallest focused change in the root package and update README/examples/changelog when behavior changes. +2. Add or update focused tests in `structconf_test.go` for precedence, tag handling, validation, or CLI behavior. +3. Run `go test ./...` for behavior changes. +4. Run `go build ./...` when examples or public APIs change. +5. Run `golangci-lint run ./...` before handing off larger changes or anything likely to touch lint-sensitive code. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f495b2d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.4.0] - 2026-05-20 + +### Added + +- Added support for configuring `[]string` fields from comma-separated CLI flags, environment variables, and default values, plus native TOML string arrays. + +## [0.3.0] - 2026-05-12 + +### Added + +- Added custom validator registration via `WithValidateFunc`. + +## [0.2.0] - 2026-03-13 + +### Added + +- Added composable command binding helpers for subcommand CLIs via `BindCommand` and `NewCommand`. +- Added a subcommands example.Now + +## [0.1.0] - 2026-01-02 + +### Added + +- Added initial struct tag based configuration loading from CLI flags, environment variables, TOML config files, and default values. +- Added support for nested structs, field tags, generated help output, validation, and marshaling. +- Added examples covering basic CLI usage, defaults, nested structs, TOML loading, overrides, globals, marshaling, and validation. + +### Fixed + +- 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 +[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 +[0.1.0]: https://github.com/tilebox/structconf/releases/tag/v0.1.0 diff --git a/README.md b/README.md index a062530..6eb360e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ go get github.com/tilebox/structconf - Order of precedence: CLI flags, 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`, `int`, `int8-64`, `uint`, `uint8-64`, `bool`, `float`, `time.Duration` +- 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` - Includes input validation using [go-playground/validator](https://github.com/go-playground/validator) @@ -202,6 +202,32 @@ $ ./app --load-config database.toml &{INFO {myuser mypassword}} ``` +### Configure string slices + +`[]string` fields are configured with comma-separated values from CLI flags, environment variables and default values. +When loading from TOML, native string arrays are supported as well. + +```go +type AppConfig struct { + AllowedOrigins []string `flag:"allowed-origins" env:"ALLOWED_ORIGINS" toml:"allowed-origins" help:"Allowed CORS origins"` +} + +func main() { + cfg := &AppConfig{} + structconf.MustLoad(cfg, "app", structconf.WithLoadConfigFlag("load-config")) +} +``` + +```bash +$ ./app --allowed-origins=https://app.example.com,https://admin.example.com +$ ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com ./app +``` + +```toml +# app.toml +allowed-origins = ["https://app.example.com", "https://admin.example.com"] +``` + ### Build subcommands You can bind configs directly to `urfave/cli` commands and compose them as subcommands. @@ -406,6 +432,9 @@ type AppConfig struct { // must be one of (case insensitive): DEBUG, INFO, WARN, ERROR LogLevel string `default:"INFO" validate:"oneofci=DEBUG INFO WARN ERROR" help:"Log level"` + + // if set, each comma-separated value must be one of (case insensitive): password, oauth, saml, magic_link + EnabledAuthProviders []string `validate:"omitempty,dive,oneofci=password oauth saml magic_link" help:"Enabled authentication providers"` } func main() { diff --git a/config.go b/config.go index deefc07..4b6badd 100644 --- a/config.go +++ b/config.go @@ -91,6 +91,29 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref apply = func(cmd *cli.Command) { fieldValue.SetString(cmd.String(flagName)) } + 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) + } + + flag = &cli.StringFlag{ + Name: flagName, + Aliases: tags.aliases, + Usage: tags.help, + DefaultText: tags.defaultValue, + Value: tags.defaultValue, + Sources: sources, + } + + apply = func(cmd *cli.Command) { + value := cmd.String(flagName) + if value == "" { + fieldValue.Set(reflect.Zero(field.Type)) + return + } + + fieldValue.Set(reflect.ValueOf(strings.Split(value, ","))) + } case reflect.Int: var value int if tags.defaultValue != "" { diff --git a/examples/10_slices/main.go b/examples/10_slices/main.go new file mode 100644 index 0000000..8f3f9c6 --- /dev/null +++ b/examples/10_slices/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + + "github.com/tilebox/structconf" +) + +type AppConfig struct { + AllowedOrigins []string `flag:"allowed-origins" env:"ALLOWED_ORIGINS" toml:"allowed-origins" validate:"omitempty,dive,url" help:"comma-separated list of allowed CORS origins"` +} + +// usage: ./app --allowed-origins=https://app.example.com,https://admin.example.com +// or: ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com ./app +// or with TOML: allowed-origins = ["https://app.example.com", "https://admin.example.com"] +func main() { + cfg := &AppConfig{} + structconf.MustLoad(cfg, "app", structconf.WithVersion("1.0.0"), structconf.WithLoadConfigFlag("load-config")) + + fmt.Printf("%v\n", cfg.AllowedOrigins) +} diff --git a/structconf.go b/structconf.go index bb24d4f..c34161b 100644 --- a/structconf.go +++ b/structconf.go @@ -11,7 +11,7 @@ import ( "github.com/urfave/cli/v3" ) -type ConfigValidator func(configPointer any) error +type ConfigValidator func(cmd *cli.Command, configPointer any) error type cliOptions struct { version string @@ -79,13 +79,13 @@ func WithCommandValidator(validator ConfigValidator) CommandOption { func WithDisableValidation() Option { return func(opts *cliOptions) { - opts.commandOptions.validator = func(configPointer any) error { return nil } + opts.commandOptions.validator = func(cmd *cli.Command, configPointer any) error { return nil } } } func WithDisableCommandValidation() CommandOption { return func(opts *commandOptions) { - opts.validator = func(configPointer any) error { return nil } + opts.validator = func(cmd *cli.Command, configPointer any) error { return nil } } } @@ -130,88 +130,6 @@ func Load(configPointer any, programName string, opts ...Option) error { // LoadArgs is like Load, but allows explicitly providing the CLI args. func LoadArgs(configPointer any, programName string, args []string, opts ...Option) error { - cfg, err := loadConfigWithArgs(configPointer, programName, args, opts...) - if err != nil { - return err - } - - return cfg.commandOptions.validator(configPointer) -} - -// 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, -// then the configured validator is run before the optional action is executed. -// -// The WithLoadConfigFlag option is not currently supported for BindCommand/NewCommand. -func NewCommand(configPointer any, commandName string, action cli.ActionFunc, opts ...CommandOption) (*cli.Command, error) { - cmd := &cli.Command{ - Name: commandName, - Action: action, - } - - err := BindCommand(cmd, configPointer, opts...) - if err != nil { - return nil, err - } - - return cmd, nil -} - -// 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 -// loading and the configured validator are run before the existing Action. -// -// The WithLoadConfigFlag option is not currently supported for BindCommand/NewCommand. -func BindCommand(command *cli.Command, configPointer any, opts ...CommandOption) error { - cfg := &commandOptions{ - validator: validate, // default validator - } - for _, opt := range opts { - opt(cfg) - } - - config, err := NewStructConfigurator(configPointer, nil) - if err != nil { - return err - } - - flags := append([]cli.Flag{}, command.Flags...) - flags = append(flags, config.Flags()...) - - if duplicate := firstDuplicateFlagName(flags); duplicate != "" { - return fmt.Errorf("duplicate flag: --%s", duplicate) - } - - command.Flags = flags - - wrappedAction := command.Action - command.Action = func(ctx context.Context, cmd *cli.Command) error { - config.Apply(cmd) - if err := cfg.validator(configPointer); err != nil { - return err - } - - if wrappedAction == nil { - return nil - } - - return wrappedAction(ctx, cmd) - } - - return nil -} - -type helpRequestedError struct { - helpText string -} - -func (e *helpRequestedError) Error() string { - return e.helpText -} - -func loadConfigWithArgs(configPointer any, programName string, args []string, opts ...Option) (*cliOptions, error) { cfg := &cliOptions{ commandOptions: commandOptions{ validator: validate, // default validator @@ -232,13 +150,13 @@ func loadConfigWithArgs(configPointer any, programName string, args []string, op config, err := NewStructConfigurator(configPointer, nil) if err != nil { - return cfg, err + return err } flags := config.Flags() flags = append(flags, loadConfigFlag) if duplicate := firstDuplicateFlagName(flags); duplicate != "" { - return cfg, fmt.Errorf("got duplicate flag name: %s", duplicate) + return fmt.Errorf("got duplicate flag name: %s", duplicate) } stdout := &bytes.Buffer{} @@ -269,13 +187,13 @@ func loadConfigWithArgs(configPointer any, programName string, args []string, op err = cmd.Run(context.Background(), args) if err != nil { if stdout.Len() > 0 { - return cfg, errors.New(err.Error() + "\n\n" + stdout.String()) + return errors.New(err.Error() + "\n\n" + stdout.String()) } - return cfg, err + return err } if stdout.Len() > 0 { // help was requested -> return an error so that we can exit - return cfg, &helpRequestedError{ + return &helpRequestedError{ helpText: stdout.String(), } } @@ -283,7 +201,7 @@ func loadConfigWithArgs(configPointer any, programName string, args []string, op config, err := NewStructConfigurator(configPointer, tomlSources) if err != nil { - return cfg, err + return err } flags := config.Flags() @@ -292,13 +210,13 @@ func loadConfigWithArgs(configPointer any, programName string, args []string, op } if duplicate := firstDuplicateFlagName(flags); duplicate != "" { - return cfg, fmt.Errorf("duplicate flag: --%s", duplicate) + return fmt.Errorf("duplicate flag: --%s", duplicate) } stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - cmd := cli.Command{ + cmd := &cli.Command{ Name: programName, Version: cfg.version, Writer: stdout, @@ -317,18 +235,91 @@ func loadConfigWithArgs(configPointer any, programName string, args []string, op err = cmd.Run(context.Background(), args) if err != nil { if stdout.Len() > 0 { - return cfg, errors.New(strings.TrimSpace(err.Error() + "\n\n" + stdout.String())) + return errors.New(strings.TrimSpace(err.Error() + "\n\n" + stdout.String())) } - return cfg, err + return err } if stdout.Len() > 0 { // help was requested -> return an error so that we can exit - return cfg, &helpRequestedError{ + return &helpRequestedError{ helpText: strings.TrimSpace(stdout.String()), } } - return cfg, nil + return cfg.commandOptions.validator(cmd, configPointer) +} + +// 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, +// then the configured validator is run before the optional action is executed. +// +// The WithLoadConfigFlag option is not currently supported for BindCommand/NewCommand. +func NewCommand(configPointer any, commandName string, action cli.ActionFunc, opts ...CommandOption) (*cli.Command, error) { + cmd := &cli.Command{ + Name: commandName, + Action: action, + } + + err := BindCommand(cmd, configPointer, opts...) + if err != nil { + return nil, err + } + + return cmd, nil +} + +// 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 +// loading and the configured validator are run before the existing Action. +// +// The WithLoadConfigFlag option is not currently supported for BindCommand/NewCommand. +func BindCommand(command *cli.Command, configPointer any, opts ...CommandOption) error { + cfg := &commandOptions{ + validator: validate, // default validator + } + for _, opt := range opts { + opt(cfg) + } + + config, err := NewStructConfigurator(configPointer, nil) + if err != nil { + return err + } + + flags := append([]cli.Flag{}, command.Flags...) + flags = append(flags, config.Flags()...) + + if duplicate := firstDuplicateFlagName(flags); duplicate != "" { + return fmt.Errorf("duplicate flag: --%s", duplicate) + } + + command.Flags = flags + + wrappedAction := command.Action + command.Action = func(ctx context.Context, cmd *cli.Command) error { + config.Apply(cmd) + if err := cfg.validator(command, configPointer); err != nil { + return err + } + + if wrappedAction == nil { + return nil + } + + return wrappedAction(ctx, cmd) + } + + return nil +} + +type helpRequestedError struct { + helpText string +} + +func (e *helpRequestedError) Error() string { + return e.helpText } func firstDuplicateFlagName(flags []cli.Flag) string { diff --git a/structconf_test.go b/structconf_test.go index a296e67..0f2d9fe 100644 --- a/structconf_test.go +++ b/structconf_test.go @@ -15,7 +15,7 @@ import ( "github.com/urfave/cli/v3" ) -func Test_loadConfigFullyTagged(t *testing.T) { +func Test_LoadArgsFullyTagged(t *testing.T) { type config struct { Value string `flag:"value" env:"VALUE" default:"value-from-default-tag" toml:"value"` Nested struct { @@ -60,7 +60,7 @@ func Test_loadConfigFullyTagged(t *testing.T) { SetArgsForTest(t, tt.args.cliArgs) // set cli args, and clean up after the test - _, err := loadConfigWithArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) + err := LoadArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) require.NoError(t, err) assert.Equal(t, tt.wantValue, config.Value) @@ -71,7 +71,7 @@ func Test_loadConfigFullyTagged(t *testing.T) { } } -func Test_loadConfigDefaultTags(t *testing.T) { +func Test_LoadArgsDefaultTags(t *testing.T) { type config struct { Value string Nested struct { @@ -116,7 +116,7 @@ func Test_loadConfigDefaultTags(t *testing.T) { SetArgsForTest(t, tt.args.cliArgs) // set cli args, and clean up after the test - _, err := loadConfigWithArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) + err := LoadArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) require.NoError(t, err) assert.Equal(t, tt.wantValue, config.Value) @@ -127,7 +127,65 @@ func Test_loadConfigDefaultTags(t *testing.T) { } } -func Test_loadConfigPrecedence(t *testing.T) { +func Test_LoadArgsStringSlice(t *testing.T) { + type config struct { + Values []string `flag:"values" env:"VALUES" default:"one,two"` + } + + tests := []struct { + name string + cliArgs []string + envValue string + toml string + want []string + }{ + { + name: "parse comma-separated flag value", + cliArgs: []string{"my-program", "--values", "three,four"}, + want: []string{"three", "four"}, + }, + { + name: "parse comma-separated env value", + cliArgs: []string{"my-program"}, + envValue: "five,six", + want: []string{"five", "six"}, + }, + { + name: "parse toml string array value", + cliArgs: []string{"my-program"}, + toml: `values = ["seven", "eight"]`, + want: []string{"seven", "eight"}, + }, + { + name: "parse comma-separated default value", + cliArgs: []string{"my-program"}, + want: []string{"one", "two"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config{} + 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) + } + SetArgsForTest(t, cliArgs) + if tt.envValue != "" { + t.Setenv("VALUES", tt.envValue) + } + + err := LoadArgs(cfg, "my-program", os.Args, WithDefaultLoadConfigFlag()) + require.NoError(t, err) + + assert.Equal(t, tt.want, cfg.Values) + }) + } +} + +func Test_LoadArgsPrecedence(t *testing.T) { type config struct { Value string `default:"value-from-default-tag"` Nested struct { @@ -223,7 +281,7 @@ duration = "1m5s" t.Setenv(key, value) // set env vars, and clean up after the test } - _, err := loadConfigWithArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) + err := LoadArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) require.NoError(t, err) assert.Equal(t, tt.wantValue, config.Value) @@ -233,7 +291,7 @@ duration = "1m5s" } } -func Test_loadConfigMultipleTomlFilesPrecedence(t *testing.T) { +func Test_LoadArgsMultipleTomlFilesPrecedence(t *testing.T) { type config struct { Value string Second string @@ -265,7 +323,7 @@ second = "second_nested_config" SetArgsForTest(t, []string{"my-program", "--load-config", firstConfigPath + "," + secondConfigPath}) cfg := &config{} - _, err := loadConfigWithArgs(cfg, "my-program", os.Args, WithDefaultLoadConfigFlag()) + err := LoadArgs(cfg, "my-program", os.Args, WithDefaultLoadConfigFlag()) require.NoError(t, err) assert.Equal(t, "first_config", cfg.Value) @@ -274,7 +332,7 @@ second = "second_nested_config" assert.Equal(t, "second_nested_config", cfg.Nested.Second) } -func Test_loadConfigExtraFlags(t *testing.T) { +func Test_LoadArgsExtraFlags(t *testing.T) { tests := []struct { name string cfg any @@ -302,7 +360,7 @@ func Test_loadConfigExtraFlags(t *testing.T) { t.Run(tt.name, func(t *testing.T) { SetArgsForTest(t, []string{"my-program", "--some-string", "hello", "--some-int", "42", "--unknown-flag", "value"}) - _, err := loadConfigWithArgs(tt.cfg, "my-program", os.Args, tt.loadOpts...) + err := LoadArgs(tt.cfg, "my-program", os.Args, tt.loadOpts...) require.Error(t, err) assert.Contains(t, err.Error(), "flag provided but not defined: -unknown-flag") assert.Contains(t, err.Error(), "USAGE:") @@ -319,14 +377,14 @@ func Test_PrintCorrectUsage(t *testing.T) { SetArgsForTest(t, []string{"my-program", "--unknown-value", "to_trigger_usage"}) - _, err := loadConfigWithArgs(&config{}, "my-program", os.Args) + err := LoadArgs(&config{}, "my-program", os.Args) require.Error(t, err) assert.Contains(t, err.Error(), "--documented-value string Description of the documented value [$DOCUMENTED_VALUE]") assert.Contains(t, err.Error(), "--value-with-default string A documented value that has a default (default: default) [$VALUE_WITH_DEFAULT]") } -func Test_loadConfigDuplicates(t *testing.T) { +func Test_LoadArgsDuplicates(t *testing.T) { tests := []struct { name string cfg any @@ -366,7 +424,7 @@ func Test_loadConfigDuplicates(t *testing.T) { t.Run(tt.name, func(t *testing.T) { SetArgsForTest(t, []string{"my-program"}) // no args set - _, err := loadConfigWithArgs(tt.cfg, "my-program", os.Args) + err := LoadArgs(tt.cfg, "my-program", os.Args) if tt.wantError != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantError) @@ -398,7 +456,8 @@ func Test_LoadArgsUsesCustomValidator(t *testing.T) { cfg := &config{} customErr := errors.New("custom validation failed") - err := LoadArgs(cfg, "my-program", []string{"my-program", "--name", "Tilebox"}, WithValidator(func(configPointer any) error { + err := LoadArgs(cfg, "my-program", []string{"my-program", "--name", "Tilebox"}, WithValidator(func(cmd *cli.Command, configPointer any) error { + assert.Equal(t, "my-program", cmd.Name) assert.Same(t, cfg, configPointer) assert.Equal(t, "Tilebox", cfg.Name) return customErr @@ -412,7 +471,8 @@ func Test_LoadArgsCustomValidatorReplacesDefaultValidator(t *testing.T) { } cfg := &config{} - err := LoadArgs(cfg, "my-program", []string{"my-program"}, WithValidator(func(configPointer any) error { + err := LoadArgs(cfg, "my-program", []string{"my-program"}, WithValidator(func(cmd *cli.Command, configPointer any) error { + assert.Equal(t, "my-program", cmd.Name) assert.Same(t, cfg, configPointer) return nil })) @@ -505,7 +565,8 @@ func Test_BindCommandUsesCustomValidator(t *testing.T) { }, } - err := BindCommand(cmd, cfg, WithCommandValidator(func(configPointer any) error { + err := BindCommand(cmd, cfg, WithCommandValidator(func(cmd *cli.Command, configPointer any) error { + assert.Equal(t, "greet", cmd.Name) assert.Same(t, cfg, configPointer) assert.Equal(t, "Tilebox", cfg.Name) return customErr @@ -584,7 +645,7 @@ Configuration error: NumberBetween0to10 - gte } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validate(tt.args.config) + err := validate(nil, tt.args.config) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) diff --git a/toml.go b/toml.go index d6b2369..cb8c38e 100644 --- a/toml.go +++ b/toml.go @@ -97,13 +97,28 @@ func (mvs *mapsValueSource) GoString() string { func (mvs *mapsValueSource) Lookup() (string, bool) { for _, ms := range mvs.maps { if v, ok := ms.Lookup(mvs.key); ok { // return the first defaultValue found - return fmt.Sprintf("%+v", v), true + return formatMapSourceValue(v), true } } return "", false } +func formatMapSourceValue(value any) string { + switch v := value.(type) { + case []string: + return strings.Join(v, ",") + case []any: + values := make([]string, 0, len(v)) + for _, item := range v { + values = append(values, fmt.Sprintf("%+v", item)) + } + return strings.Join(values, ",") + default: + return fmt.Sprintf("%+v", value) + } +} + func NewValueSourceFromMaps(key string, sources ...cli.MapSource) cli.ValueSource { return &mapsValueSource{ key: key, diff --git a/validate.go b/validate.go index 785d1e9..9d9d84b 100644 --- a/validate.go +++ b/validate.go @@ -6,9 +6,10 @@ import ( "fmt" "github.com/go-playground/validator/v10" + "github.com/urfave/cli/v3" ) -func validate(configPointer any) error { +func validate(_ *cli.Command, configPointer any) error { configValidator := validator.New(validator.WithRequiredStructEnabled()) err := configValidator.Struct(configPointer)