From 2024016a557bb96790bc28d87ba8f8769ea3dd83 Mon Sep 17 00:00:00 2001 From: Lukas Bindreiter Date: Mon, 9 Nov 2026 17:08:44 +0100 Subject: [PATCH 1/2] Add typed slices, optional fields, flexible arguments, and structured validation errors --- .github/workflows/main.yml | 11 +- CHANGELOG.md | 31 ++ README.md | 532 +++++------------- config.go | 348 +++++++++--- examples/04_toml/config.toml | 5 + examples/10_slices/config.toml | 3 + examples/10_slices/main.go | 9 +- examples/11_arguments/main.go | 11 +- examples/12_updates/main.go | 101 ++++ examples/13_forwarding/main.go | 41 ++ examples/README.md | 138 +++++ go.mod | 26 +- go.sum | 50 +- marshal.go | 30 +- metadata.go | 91 ++++ slice.go | 166 ++++++ structconf.go | 114 ++-- structconf_test.go | 965 ++++++++++++++++++++++++++++++++- tags.go | 38 +- toml.go | 56 +- validate.go | 210 ++++++- 21 files changed, 2313 insertions(+), 663 deletions(-) create mode 100644 examples/04_toml/config.toml create mode 100644 examples/10_slices/config.toml create mode 100644 examples/12_updates/main.go create mode 100644 examples/13_forwarding/main.go create mode 100644 examples/README.md create mode 100644 metadata.go create mode 100644 slice.go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 539cb99..280862a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,12 +9,14 @@ on: jobs: tests: - name: Run tests + name: Run tests (Go ${{ matrix.go-version }}) strategy: matrix: os: [Ubuntu] - go-version: ["1.26.x"] + go-version: ["1.26.x", "1.27.x"] runs-on: ${{ matrix.os }}-latest + env: + GOTOOLCHAIN: local permissions: contents: read # for golangci-lint-action steps: @@ -32,7 +34,7 @@ jobs: - name: Build run: go build -v ./... - name: Run Tests - run: go test -v ./... | go-junit-report -set-exit-code > test-report.xml + run: go test -v ./... 2>&1 | tee /dev/stderr | go-junit-report -set-exit-code > test-report.xml - name: Test Summary uses: test-summary/action@v2 with: @@ -41,5 +43,4 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v9 with: - version: v2.12.2 - args: --timeout=5m --verbose + version: v2.15.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b3e674..7a3d6b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `slice:"comma-separated"` (the default) and `slice:"repeated-flag"` for per-field slice parsing, plus loading slices of all supported scalar types. Repeated flags preserve literal commas; native TOML arrays preserve element boundaries in both modes. +- Added scalar pointer fields to distinguish omitted values from explicit zero values. Defaults populate pointers too; nil pointers are omitted when marshaling. +- Added positional `nargs` cardinality: `?`, `*`, `+`, and positive integer counts. Omitting `nargs` retains the existing single-token/fallback behavior. +- Added `WithDisableImplicitEnvVars()` for loading and command binding, retaining only explicitly named environment sources. +- Added `FieldInfo`, `Describe`, `ValidationIssue`, `ValidationError`, `NewValidationError`, and `Validate` for shared field metadata and application-defined validation errors. +- Added `file:"true"` on string flags, including scalar pointers, for urfave file completion. + +### Changed + +- Requires Go 1.26 or later; CI tests Go 1.26 and 1.27. +- `Load` and `LoadArgs` now reject leftover tokens when positional bindings exist. `BindCommand` and `NewCommand` still expose leftovers through `cmd.Args()`. +- Required positional bindings cannot follow fields with omitted `nargs`, just as they cannot follow `nargs:"?"`. Use `nargs:"1"` for preceding required fields; `validate:"required"` does not determine positional cardinality. +- `WithValidator` and `WithDisableValidation` now work with both loading and command binding. Their command-only counterparts remain supported as aliases. +- `MarshalAsSlogDict` now preserves leaf value types using `slog.Any`. JSON logs contain numbers, booleans, and arrays instead of stringified values; consumers expecting strings must adapt. Durations remain strings and secrets remain redacted. +- Tag validation and structconf-owned positional/slice-source errors now expose structured field information. Tag errors name the affected input and explain required values, choices, lengths, bounds, and common formats without echoing rejected values. Urfave flag errors retain their native types. +- Removed unused internal JSON/YAML tag handling; neither format was supported for loading or marshaling. +- `Option` and `CommandOption` are now sealed interfaces so `WithDisableImplicitEnvVars()` can be passed to either API. Existing option constructors and option slices remain supported. + +### Fixed + +- Pointer positional arguments work with urfave's typed getters and report scalar input types. Pointer flags without declared defaults no longer advertise zero-value defaults. +- Omitted non-pointer positional arguments without `nargs` preserve initialized values when no fallback is available. +- Config-file discovery no longer parses environment values before TOML sources are available, and help includes the final command's argument and source metadata. +- Slice flags report occurrence counts and honor per-flag help stringers, including inherited flags and source fallbacks. +- Marshaling skips nil nested struct pointers without allocating or modifying the config. +- Invalid indexed validation paths no longer resolve to unrelated CLI bindings. Validation resolves field metadata once per call. +- Fixed `MarshalAsMap` and `MarshalAsSlogDict` failing on slices. Elements use the same primitive conversions and string redaction as scalar fields, including in nested slices. +- Aligned positional arguments with flags when reading empty source values: numeric and duration fields fall back, strings and string slices are cleared, and booleans become `false`. + ## [0.5.0] - 2026-05-20 ### Added diff --git a/README.md b/README.md index b94fabb..f7dd7bf 100644 --- a/README.md +++ b/README.md @@ -1,489 +1,215 @@ # structconf -Opinionated struct tag based configuration for go - parse CLI args, environment vars or config files into a unified config struct - based on [urfave/cli](https://github.com/urfave/cli). - -## Installation +Define your CLI inputs in a Go struct. A command like this: ```bash -go get github.com/tilebox/structconf +./my-program --limit 50 --collections optical,radar --db-user "dummy" my-dataset ``` -## Features - -- 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`, `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` - -## Usage +maps to these fields: ```go -package main - -import ( - "fmt" - "github.com/tilebox/structconf" -) - -type ProgramConfig struct { - Name string - Greet bool +type Args struct { + Dataset string `arg:"0" nargs:"1"` // my-dataset + Limit int `default:"20"` // 50 + Collections []string // ["optical", "radar"] + APIKey string `env:"API_KEY"` // read from $API_KEY if not passed on the CLI + DB Database } -func main() { - cfg := &ProgramConfig{} - structconf.MustLoad(cfg, "greetings") - if cfg.Greet { - fmt.Printf("Hello %s!\n", cfg.Name) - } +type Database struct { + User string // "dummy", from --db-user } ``` -Produces the following program: - -``` -$ ./greetings --greet --name "World" -Hello World! -``` - -Alternatively, also environment variables are read out of the box: - -``` -$ GREET=true NAME=World ./greetings -Hello World! -``` +Or put the same values in `config.toml`: -And also a help message is generated out of the box: - -``` -$ ./greetings -h - -NAME: - simple_cli - A new cli application - -USAGE: - simple_cli [global options] - -VERSION: - 1.0.0 +```toml +api-key = "example-key" +dataset = "my-dataset" +limit = 50 +collections = ["optical", "radar"] -GLOBAL OPTIONS: - --name value [$NAME] - --greet (default: false) [$GREET] - --help, -h show help - --version, -v print the version +[db] +user = "dummy" ``` -## Features - -### Specify default values and help messages - -```go -type ProgramConfig struct { - Name string `default:"World" help:"Whom to greet"` - Greet bool `help:"Whether or not to greet"` -} - -func main() { - cfg := &ProgramConfig{} - structconf.MustLoad(cfg, - "greetings", - structconf.WithVersion("1.0.0"), - structconf.WithDescription("Print a greeting"), - structconf.WithLongDescription("A CLI for printing a greeting to the console"), - ) - - if cfg.Greet { - fmt.Printf("Hello %s!\n", cfg.Name) - } -} - -``` +Enable file loading with `WithDefaultLoadConfigFlag()`, then run: ```bash -$ ./greetings -h - -NAME: - greetings - Print a greeting - -USAGE: - greetings [global options] - -VERSION: - 1.0.0 - -DESCRIPTION: - A CLI for printing a greeting to the console - -GLOBAL OPTIONS: - --name value Whom to greet (default: World) [$NAME] - --greet Whether or not to greet (default: false) [$GREET] - --help, -h show help - --version, -v print the version -``` - -### Nest structures - -```go -type DatabaseConfig struct { - User string - Password string -} - -type ServerConfig struct { - Host string - Port int -} - -type AppConfig struct { - LogLevel string `default:"INFO"` - - Server ServerConfig - Database DatabaseConfig -} - -func main() { - cfg := &AppConfig{} - structconf.MustLoad(cfg, "app") - - fmt.Printf("%v", cfg) -} -``` - -``` -$ ./app --database-user=myuser --database-password=mypassword --server-host=localhost --server-port=8080 --log-level=DEBUG - -&{DEBUG {localhost 8080} {myuser mypassword}} +./my-program --load-config=config.toml ``` -### Load configuration from TOML files +structconf handles parsing, help, and validation using [urfave/cli](https://github.com/urfave/cli) and [go-playground/validator](https://github.com/go-playground/validator). -```go -type DatabaseConfig struct { - User string - Password string -} -type AppConfig struct { - LogLevel string `default:"INFO"` - Database DatabaseConfig -} +## Struct tags -func main() { - cfg := &AppConfig{} - structconf.MustLoad(cfg, - "app", - // adds a --load-config flag to load config from TOML files - structconf.WithLoadConfigFlag("load-config"), - ) +Tags are optional. By default, structconf uses exported field names for flags, environment variables, and TOML keys. Nested fields include their parents' names. - fmt.Printf("%v", cfg) -} -``` +| Tag | Example | Purpose | +| --- | --- | --- | +| `flag` | `flag:"output"` | Override the flag name. `flag:"-"` excludes a field from configuration. | +| `arg` | `arg:"0"` | Read a positional argument instead of a flag. Number fields starting at zero. | +| `alias` | `alias:"-n"` | Add flag aliases, comma-separated and prefixed with `-`. | +| `env` | `env:"API_KEY"` | Override the environment variable name. `env:"-"` disables this source. | +| `default` | `default:"10"` | Use this value when no input source supplies one. | +| `toml` | `toml:"api-key"` | Override the TOML key. `toml:"-"` disables this source. | +| `help` | `help:"Maximum results"` | Describe the field in CLI help and field metadata. | +| `validate` | `validate:"gte=1"` | Apply go-playground/validator constraints after loading. | +| `secret` | `secret:"true"` | Redact string values in structconf's marshaling helpers. | +| `global` | `global:"true"` | Leave parent prefixes out of the field's names. | +| `slice` | `slice:"repeated-flag"` | Append one value each time the flag is passed, without splitting commas. Defaults to `comma-separated`. | +| `nargs` | `nargs:"+"` | Set how many positional values to accept: `?`, `*`, `+`, or a positive integer. | +| `file` | `file:"true"` | Enable file completion for `string` and `*string` flags. | -Define a config file +Fields can be strings, booleans, signed and unsigned integers, floats, or `time.Duration`, including pointers and slices of these types. Use nested structs to group configuration. -```toml -# database.toml -[database] -user = "myuser" -password = "mypassword" -``` +## Load configuration -Run the program +Install with Go 1.26 or later: ```bash -$ ./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"] +go get github.com/tilebox/structconf ``` -### Build subcommands - -You can bind configs directly to `urfave/cli` commands and compose them as subcommands. +Define a struct and pass its pointer to `MustLoad`. Save this as `main.go` in your application: ```go package main import ( - "context" "fmt" - "os" - "strings" "github.com/tilebox/structconf" - "github.com/urfave/cli/v3" ) -type GreetConfig struct { - Name string `default:"World"` - Loud bool +type Config struct { + Endpoint string `default:"https://api.example.com" validate:"url" help:"API endpoint"` + Limit int `default:"20" validate:"gte=1" help:"Maximum results"` } func main() { - greetCfg := &GreetConfig{} - - greetCmd, err := structconf.NewCommand(greetCfg, "greet", func(ctx context.Context, cmd *cli.Command) error { - if greetCfg.Loud { - fmt.Println(strings.ToUpper(greetCfg.Name)) - return nil - } - - fmt.Println(greetCfg.Name) - return nil - }) - if err != nil { - panic(err) - } - - root := &cli.Command{ - Name: "app", - Commands: []*cli.Command{greetCmd}, - } - - if err := root.Run(context.Background(), os.Args); err != nil { - panic(err) - } + cfg := &Config{} + structconf.MustLoad(cfg, "query", + structconf.WithDescription("Query records"), + structconf.WithDefaultLoadConfigFlag(), + ) + fmt.Printf("Query %s, limit %d\n", cfg.Endpoint, cfg.Limit) } ``` -`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"` -} +Run it with flags or environment variables. To try file loading, create `config.toml` containing `limit = 30`: -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" +```bash +go run . --limit 50 +LIMIT=50 go run . +go run . --load-config config.toml --limit 50 +go run . --help ``` -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. +When a field has more than one source, the first one wins: **CLI → TOML files → environment variables → defaults**. Flags and TOML keys use kebab-case; environment variables use SCREAMING_SNAKE_CASE. For example, `Server.Port` becomes `--server-port`, `SERVER_PORT`, and `port` under TOML's `[server]` table. -### Parse custom arg slices +Use `Load` if you want to handle errors yourself instead of printing them and exiting. For tests or calls from Go code, `LoadArgs` and `MustLoadArgs` take an argument slice that includes the program name. -If you need to parse a specific arg slice (for tests or embedding), use `LoadArgs`: +Environment variables are read automatically. Pass `WithDisableImplicitEnvVars()` to read only variables named in nonempty `env` tags. This works with both `Load` and command binding. -```go -cfg := &AppConfig{} -err := structconf.LoadArgs(cfg, "app", []string{"app", "--log-level", "DEBUG"}) -if err != nil { - panic(err) -} -``` +## Bind commands -### Shell completion - -Enable completion in code: +Use `BindCommand` with an existing urfave command. It adds the inputs from your struct, then loads and validates them before the action runs: ```go -structconf.MustLoad(cfg, "app", structconf.WithShellCompletions()) -``` - -Then install it in your shell: - -```bash -# bash (add to ~/.bashrc) -source <(app completion bash) - -# zsh (add to ~/.zshrc) -source <(app completion zsh) - -# fish -app completion fish > ~/.config/fish/completions/app.fish +type UpdateArgs struct { + Workflow string `arg:"0" nargs:"1" help:"Workflow to update"` + Summary *string `help:"New summary; an empty string clears it"` + Enabled *bool `help:"Enable or disable the workflow"` +} + +func updateCommand() (*cli.Command, error) { + args := &UpdateArgs{} + cmd := &cli.Command{ + Name: "update", + Usage: "Update a workflow", + Action: func(_ context.Context, _ *cli.Command) error { + if args.Summary != nil { + fmt.Printf("Set %s summary to %q\n", args.Workflow, *args.Summary) + } + if args.Enabled != nil { + fmt.Printf("Set %s enabled to %t\n", args.Workflow, *args.Enabled) + } + return nil + }, + } + err := structconf.BindCommand(cmd, args, structconf.WithDisableImplicitEnvVars()) + return cmd, err +} ``` -### Override auto-generated names for fields +Add the command to a root command's `Commands` slice and run it with `root.Run(ctx, os.Args)`. `NewCommand(args, name, action, options...)` creates and binds a command in one call. See the [runnable subcommand example](examples/09_subcommands). -By default, field names are converted to flags, env vars and toml properties using the following rules: +Use pointers when an omitted flag should mean “leave this field alone.” Passing `--summary ""` or `--enabled=false` gives you a non-nil pointer, so you can clear a string or turn something off. Defaults also set pointers, so leave defaults off fields used for partial updates. -- For flags, (nested) field names are converted to kebab-case, e.g. `MyFieldName` becomes `--my-field-name` -- For env vars, field names are converted to uppercase, e.g. `MyFieldName` becomes `MY_FIELD_NAME` -- 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` +Command binding supports flags, arguments, environment variables, and defaults. TOML loading is available through `Load` / `LoadArgs`, not command binding. Set shell completion on the urfave root command, or use `WithShellCompletions()` with `Load`. -You can override these default rules at any point by using the `flag`, `arg`, `env` and `toml` tags. +## Slices and positional arguments ```go -type AppConfig struct { - // configure using either: - // --level flag - // $LOGGING_LEVEL env var - // log-level toml property - LogLevel string `flag:"level" env:"LOGGING_LEVEL" toml:"log-level" default:"INFO"` - - // will not be configurable at all - Ignored string `flag:"-" env:"-" toml:"-"` +type QueryArgs struct { + Dataset string `arg:"0" nargs:"1"` + Collections []string `flag:"collections"` + Filters []string `flag:"filter" slice:"repeated-flag"` + Counts []int `flag:"counts"` } +// query --collections a,b --filter "name = 'a,b'" --filter "cloud < 20" --counts 3,7 dataset ``` -### Configure certain fields as global regardless of how deeply nested they are - -```go -type AppConfig struct { - Deeply DeeplyConfig -} +Slices split commas by default. Use `slice:"repeated-flag"` when each flag should add one value, even if it contains commas. This is useful for filter expressions. The first flag replaces any values from TOML, environment variables, or defaults. Repeating it appends in repeated-flag mode and replaces in comma-separated mode. TOML arrays keep each element intact in either mode. -type DeeplyConfig struct { - Nested NestedConfig -} - -type NestedConfig struct { - Name string `global:"true"` // will be --name (and $NAME) instead of --deeply-nested-name and $DEEPLY_NESTED_NAME -} +For positional arguments, use `nargs:"1"` for one required value, `?` for an optional value, `*` for zero or more values, or `+` for one or more. Use a slice for `*`, `+`, or a fixed count above one. Number fields from zero without gaps, put required fields before optional ones, and leave `*` or `+` for the last field. Without `nargs`, a field reads at most one argument and splits commas if it's a slice. -func main() { - cfg := &AppConfig{} - structconf.MustLoad(cfg, "app") - fmt.Println(cfg.Deeply.Nested.Name) -} -``` +When you define positional arguments, `Load` rejects extra arguments. `BindCommand` and `NewCommand` leave them in `cmd.Args()` for your action to handle. See the [argument and forwarding examples](examples/README.md#positional-arguments). -```bash -./app --name Tilebox -Tilebox -``` +## Validation and errors -## Auto-marshalling and log/slog integration +`validate` tags run by default. Use `WithValidator` to replace validation, or `WithDisableValidation` to skip it. Both work with loading and command binding. -For inspection or logging purposes sometimes it is useful to marshal a whole config struct. -However, when doing so, often sensitive fields need to be redacted. - -`structconf` provides marshaling helpers for this use case. +Validation messages use flag or positional argument names, falling back to Go field +paths for fields without a CLI binding. Common constraints explain what to supply: +`--name must contain at least 1 character`, `--limit must be at most 100`, or +` must be a valid UUID`. Length constraints count characters in strings and +items in collections; numeric constraints describe the allowed value. Messages +do not include rejected input values. `Rule` and `Param` remain available in each +issue for applications that need to inspect the constraint. +Call `structconf.Validate` inside your validator to keep the tag checks, then add your own: ```go -import ( - "fmt" - "log/slog" - - "github.com/tilebox/structconf" -) - -type DatabaseConfig struct { - User string - Password string `secret:"true"` +type InputArgs struct { + Input *string + InputFile *string `file:"true"` } -type AppConfig struct { - LogLevel string `default:"DEBUG"` - Database DatabaseConfig -} - - -func main() { - cfg := &AppConfig{} - structconf.MustLoad(cfg, "app") - - asMap, err := structconf.MarshalAsMap(cfg) - if err != nil { - panic(err) +func validateInput(_ *cli.Command, config any) error { + if err := structconf.Validate(config); err != nil { + return err } - fmt.Println(asMap) - - // includes an integration with log/slog to convert the config struct to a recursive slog.Group structure - config, err := structconf.MarshalAsSlogDict(cfg, "config") - if err != nil { - panic(err) + args := config.(*InputArgs) + if args.Input != nil && args.InputFile != nil { + return structconf.NewValidationError( + "choose either --input or --input-file", args, "Input", "InputFile", + ) } - slog.Info("Program config loaded successfully", config) + return nil } ``` -```bash -$ ./app --database-user=my-user --database-password=very-secret-password --log-level=INFO -map[database:map[password:ve***rd user:my-user] log-level:DEBUG] -INFO Program config loaded successfully config.log-level=DEBUG config.database.user=my-user config.database.password=ve***rd -``` +Use `errors.As` to find a `*ValidationError`, then read its `Issues` for messages, field names, and help text. You can also construct these errors yourself. If you only need field names and help, call `Describe(config)` without loading any values. The [update example](examples/12_updates) shows how to validate inputs and print errors with help for the affected fields. -### Validation +Not every error is a `ValidationError`: urfave flag errors and file I/O errors retain their native types. The `secret` tag does not redact arbitrary error messages. -`structconf` includes validation using [go-playground/validator](https://github.com/go-playground/validator) - -```go -type AppConfig struct { - // must be set (not empty) - Host string `validate:"required" help:"Hostname (required)"` - - // must be an integer between 1 and 65535 - Port int `validate:"gte=1,lte=65535" default:"8080" help:"Server port"` - - // If set, it must be a valid path to a directory - Path string `validate:"omitempty,dir" help:"A valid path"` - - // 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"` -} +## Logging configuration -func main() { - cfg := &AppConfig{} - structconf.MustLoad(cfg, "app") -} -``` +Use `MarshalAsMap(config)` to inspect a config or `MarshalAsSlogDict(config, "config")` to log it with slog. Both handle nested structs and slices, skip nil pointers, and redact strings tagged `secret:"true"` without changing the config. Keys follow flag names. structconf does not read JSON or YAML tags or config files. See the [logging example](examples/07_marshal). -```bash -$ ./app --port=0 --path=/tmp/ -Missing required configuration: AppConfig.Host -Configuration error: Port - gte -``` - -If you want to load config without running validation, pass `WithDisableValidation`: - -```go -type AppConfig struct { - Host string `validate:"required"` -} - -func main() { - cfg := &AppConfig{} - structconf.MustLoad(cfg, "app", structconf.WithDisableValidation()) -} -``` +## Examples -For configs bound to subcommands with `BindCommand` / `NewCommand`, use `WithDisableCommandValidation` instead. +See [examples/](examples/README.md) for runnable programs covering TOML, subcommands, repeated flags, partial updates, error handling, and command forwarding. diff --git a/config.go b/config.go index 82546a3..c688a95 100644 --- a/config.go +++ b/config.go @@ -1,6 +1,7 @@ package structconf import ( + "errors" "fmt" "reflect" "slices" @@ -30,6 +31,7 @@ type structReflector struct { applyFuncs []func(*cli.Command) // functions to call after flags are parsed, to apply values to the struct tomlSources []cli.MapSource + options commandOptions } func (r *structReflector) Flags() []cli.Flag { @@ -63,6 +65,12 @@ type configArgument struct { fieldValue reflect.Value sources cli.ValueSourceChain defaultValue string + hasDefault bool + nargs string + min int + max int + info FieldInfo + secret bool } func (a *configArgument) HasName(name string) bool { @@ -70,36 +78,39 @@ func (a *configArgument) HasName(name string) bool { } 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) + rest, err := a.parse(args) + if err != nil { + message := err.Error() + if a.secret { + message = "invalid value for argument " + a.argumentName } + return rest, &ValidationError{Issues: []ValidationIssue{{Message: message, Fields: []FieldInfo{a.info}}}} } - - return args, nil + return rest, nil } func (a *configArgument) Usage() string { - return a.argumentName + switch a.nargs { + case "?": + return "[" + a.argumentName + "]" + case "*": + return "[" + a.argumentName + " ...]" + case "+": + return a.argumentName + " [" + a.argumentName + " ...]" + case "": + return a.argumentName + default: + return strings.TrimSpace(strings.Repeat(a.argumentName+" ", a.max)) + } } func (a *configArgument) Get() any { + if a.fieldValue.Kind() == reflect.Pointer { + if a.fieldValue.IsNil() { + return reflect.Zero(a.field.Type.Elem()).Interface() + } + return a.fieldValue.Elem().Interface() + } return a.fieldValue.Interface() } @@ -115,7 +126,96 @@ func (a *configArgument) UsageText() string { return a.usageText } +func (a *configArgument) parse(args []string) ([]string, error) { + if a.field.Type.Kind() == reflect.Pointer || a.nargs != "" { + a.fieldValue.SetZero() + } + if len(args) > 0 { + count := len(args) + if a.max >= 0 { + count = min(count, a.max) + } + if err := a.setValues(args[:count], true); err != nil { + return args, fmt.Errorf("invalid value %q for argument %s: %w", args[0], a.argumentName, err) + } + return args[count:], nil + } + + for _, source := range a.sources.Chain { + if a.field.Type.Kind() == reflect.Slice { + values, found := sliceSourceValues(source, false) + if !found { + continue + } + return args, a.setValues(values, false) + } + value, found := source.Lookup() + if !found { + continue + } + if value == "" { + t := a.field.Type + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + kind := t.Kind() + if kind == reflect.Bool { + value = "false" + } else if kind != reflect.String && kind != reflect.Slice { + continue + } + // String slices accept empty values like their StringFlag counterparts. + } + if err := a.setValues([]string{value}, false); 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.hasDefault { + values := []string{a.defaultValue} + if a.field.Type.Kind() == reflect.Slice { + values = splitSliceValue(a.defaultValue, false) + } + if err := a.setValues(values, false); err != nil { + return args, fmt.Errorf("failed to parse default value %s for argument %s: %w", a.defaultValue, a.argumentName, err) + } + return args, nil + } + + return args, a.checkCount(0) +} + +func (a *configArgument) checkCount(count int) error { + if count < a.min || (a.nargs != "" && a.max >= 0 && count > a.max) { + return fmt.Errorf("argument %s expects %s values, got %d", a.argumentName, a.nargs, count) + } + return nil +} + +func (a *configArgument) setValues(values []string, fromCLI bool) error { + if err := a.checkCount(len(values)); err != nil { + return err + } + if a.field.Type.Kind() == reflect.Slice { + if fromCLI && a.nargs == "" { + values = splitSliceValue(values[0], false) + } + parsed, err := parseSlice(a.field, values) + if err != nil { + return err + } + a.fieldValue.Set(parsed) + return nil + } + return setFieldValueFromString(a.field, a.fieldValue, values[0]) +} + func typeName(valueType reflect.Type) string { + if valueType.Kind() == reflect.Pointer { + valueType = valueType.Elem() + } return valueType.String() } @@ -123,6 +223,33 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref if tags == nil { return nil } + if tags.flag == "-" && tags.arg == "" { + return nil + } + if err := validateSupportedFieldType(field); err != nil { + return err + } + valueType := field.Type + if valueType.Kind() == reflect.Pointer { + valueType = valueType.Elem() + } + if tags.sliceMode != "" && (valueType.Kind() != reflect.Slice || (tags.sliceMode != "comma-separated" && tags.sliceMode != "repeated-flag")) { + return fmt.Errorf("invalid slice mode %q for field %s", tags.sliceMode, field.Name) + } + if tags.arg != "" && tags.sliceMode == "repeated-flag" { + return fmt.Errorf("slice mode repeated-flag requires a flag: %s", field.Name) + } + if tags.nargs != "" && tags.arg == "" { + return fmt.Errorf("nargs requires an argument: %s", field.Name) + } + if tags.file && (valueType.Kind() != reflect.String || tags.arg != "") { + return fmt.Errorf("file requires a string flag: %s", field.Name) + } + if raw, ok := field.Tag.Lookup("file"); ok { + if _, err := strconv.ParseBool(raw); err != nil { + return fmt.Errorf("invalid file tag for %s: %w", field.Name, err) + } + } valueSources := r.valueSources(tags, parents) @@ -138,6 +265,9 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref } func (r *structReflector) valueSources(tags *configFieldTags, parents []*configFieldTags) []cli.ValueSource { + if r.options.skipSources { + return nil + } valueSources := make([]cli.ValueSource, 0) if tags.toml != "" && tags.toml != "-" && len(r.tomlSources) > 0 { // load from toml file unless explicitly set to "-" @@ -151,7 +281,7 @@ func (r *structReflector) valueSources(tags *configFieldTags, parents []*configF valueSources = append(valueSources, NewValueSourceFromMaps(tomlKey, r.tomlSources...)) } - if tags.env != "-" { // load from env var unless it's explicitly set to "-" + if tags.env != "-" && (!r.options.disableImplicitEnvVars || tags.explicitEnv) { envKey := tags.env if !tags.isGlobal { emvKeys := lo.Map(parents, func(parent *configFieldTags, _ int) string { return parent.env }) @@ -179,11 +309,10 @@ func (r *structReflector) processArgument(field reflect.StructField, fieldValue 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, "-") + argName := bindingName(tags, parents) + minCount, maxCount, err := argumentCounts(tags.nargs, field.Type) + if err != nil { + return fmt.Errorf("field %s: %w", field.Name, err) } for _, argument := range r.foundArguments { @@ -200,17 +329,26 @@ func (r *structReflector) processArgument(field reflect.StructField, fieldValue fieldValue: fieldValue, sources: cli.NewValueSourceChain(valueSources...), defaultValue: tags.defaultValue, + hasDefault: tags.hasDefault, + nargs: tags.nargs, + min: minCount, + max: maxCount, + info: describeField(tags, parents), + secret: tags.isSecret, }) 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 }) - flagKeys = append(flagKeys, tags.flag) - flagName = strings.Join(flagKeys, "-") + flagName := bindingName(tags, parents) + + destination := fieldValue + isPointer := field.Type.Kind() == reflect.Pointer + hideDefault := isPointer && !tags.hasDefault + if isPointer { + field.Type = field.Type.Elem() + fieldValue = reflect.New(field.Type).Elem() } sources := cli.NewValueSourceChain(valueSources...) @@ -227,35 +365,37 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: tags.defaultValue, Sources: sources, + TakesFile: tags.file, } 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) + repeated := tags.sliceMode == "repeated-flag" + var defaults []string + if tags.hasDefault { + defaults = splitSliceValue(tags.defaultValue, repeated) + } + value, err := parseSlice(field, defaults) + if err != nil { + return fmt.Errorf("invalid default for %s: %w", field.Name, err) } - flag = &cli.StringFlag{ + slice := &sliceFlag{StringFlag: &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, ","))) + }, field: field, defaults: value, value: value, repeated: repeated, info: describeField(tags, parents), secret: tags.isSecret} + flag = slice + apply = func(_ *cli.Command) { + fieldValue.Set(slice.value) } case reflect.Int: var value int @@ -272,6 +412,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -295,6 +436,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -318,6 +460,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -341,6 +484,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -348,7 +492,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl fieldValue.SetInt(int64(cmd.Int32(flagName))) } case reflect.Int64: - if _, ok := fieldValue.Interface().(time.Duration); ok { // special handling for time.Duration, which is a int64 + if _, ok := reflect.TypeAssert[time.Duration](fieldValue); ok { // special handling for time.Duration, which is a int64 var ( value time.Duration err error @@ -366,6 +510,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -390,6 +535,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -415,6 +561,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: uint(value), Sources: sources, } @@ -438,6 +585,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -461,6 +609,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -484,6 +633,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -508,6 +658,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -531,6 +682,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -555,6 +707,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -579,6 +732,7 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, + HideDefault: hideDefault, Value: value, Sources: sources, } @@ -591,19 +745,30 @@ func (r *structReflector) processFlag(field reflect.StructField, fieldValue refl r.foundFlags = append(r.foundFlags, flag) r.applyFuncs = append(r.applyFuncs, apply) + if isPointer { + r.applyFuncs = append(r.applyFuncs, func(cmd *cli.Command) { + destination.SetZero() + if cmd.IsSet(flagName) || tags.hasDefault { + destination.Set(reflect.New(field.Type)) + destination.Elem().Set(fieldValue) + } + }) + } return nil } func validateSupportedFieldType(field reflect.StructField) error { + if field.Type.Kind() == reflect.Pointer || field.Type.Kind() == reflect.Slice { + element := field.Type.Elem() + if element.Kind() == reflect.Pointer || element.Kind() == reflect.Slice || element.Kind() == reflect.Struct { + return fmt.Errorf("unsupported element type %s for field %s", element, field.Name) + } + field.Type = element + } 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: @@ -620,20 +785,24 @@ func validateSupportedFieldType(field reflect.StructField) error { } func setFieldValueFromString(field reflect.StructField, fieldValue reflect.Value, value string) error { + if field.Type.Kind() == reflect.Pointer { + field.Type = field.Type.Elem() + parsed := reflect.New(field.Type) + if err := setFieldValueFromString(field, parsed.Elem(), value); err != nil { + return err + } + fieldValue.Set(parsed) + return nil + } 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 + parsed, err := parseSlice(field, splitSliceValue(value, false)) + if err != nil { + return err } - - fieldValue.Set(reflect.ValueOf(strings.Split(value, ","))) + fieldValue.Set(parsed) case reflect.Int: parsed, err := strconv.ParseInt(value, 10, strconv.IntSize) if err != nil { @@ -659,7 +828,7 @@ func setFieldValueFromString(field reflect.StructField, fieldValue reflect.Value } fieldValue.SetInt(parsed) case reflect.Int64: - if _, ok := fieldValue.Interface().(time.Duration); ok { + if _, ok := reflect.TypeAssert[time.Duration](fieldValue); 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) @@ -740,6 +909,9 @@ func (r *structReflector) recurseStruct(anyStruct any, parents []*configFieldTag for i := range structType.NumField() { fieldType := structType.Field(i) fieldValue := structValues.Field(i) + if !fieldType.IsExported() { + continue + } tags := parseTagsWithFieldNameDefault(&fieldType.Tag, fieldType.Name) nested := slices.Clone(parents) @@ -792,19 +964,43 @@ func (r *structReflector) validateArguments() error { return fmt.Errorf("missing argument binding for index: %d", index) } } + r.Arguments() // sort before checking positional grammar + optional := false + for i, argument := range r.foundArguments { + if argument.max == -1 && i != len(r.foundArguments)-1 { + return fmt.Errorf("variadic argument %s must be last", argument.argumentName) + } + if optional && argument.min > 0 { + return fmt.Errorf("required argument %s cannot follow an optional argument", argument.argumentName) + } + optional = optional || argument.min == 0 + } return nil } func NewStructConfigurator(anyStruct any, tomlSources []cli.MapSource) (StructReflector, error) { + return newStructConfigurator(anyStruct, tomlSources, commandOptions{}) +} + +func newStructConfigurator(anyStruct any, tomlSources []cli.MapSource, options commandOptions) (StructReflector, error) { + v := reflect.ValueOf(anyStruct) + if !v.IsValid() || v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Kind() != reflect.Struct { + return nil, errors.New("configuration must be a non-nil pointer to a struct") + } + _, err := Describe(anyStruct) + if err != nil { + return nil, err + } reflector := &structReflector{ foundFlags: make([]cli.Flag, 0), foundArguments: make([]*configArgument, 0), applyFuncs: make([]func(*cli.Command), 0), tomlSources: tomlSources, + options: options, } - err := reflector.recurseStruct(anyStruct, nil) + err = reflector.recurseStruct(anyStruct, nil) if err != nil { return nil, err } @@ -815,3 +1011,29 @@ func NewStructConfigurator(anyStruct any, tomlSources []cli.MapSource) (StructRe return reflector, nil } + +func argumentCounts(nargs string, t reflect.Type) (int, int, error) { + minCount, maxCount := 0, 1 + switch nargs { + case "": + return minCount, maxCount, nil + case "?": + if t.Kind() == reflect.Slice { + return 0, 0, errors.New("nargs ? requires a scalar") + } + case "*": + maxCount = -1 + case "+": + minCount, maxCount = 1, -1 + default: + count, err := strconv.Atoi(nargs) + if err != nil || count < 1 { + return 0, 0, fmt.Errorf("invalid nargs %q", nargs) + } + minCount, maxCount = count, count + } + if maxCount != 1 && t.Kind() != reflect.Slice { + return 0, 0, fmt.Errorf("nargs %s requires a slice", nargs) + } + return minCount, maxCount, nil +} diff --git a/examples/04_toml/config.toml b/examples/04_toml/config.toml new file mode 100644 index 0000000..7a20c65 --- /dev/null +++ b/examples/04_toml/config.toml @@ -0,0 +1,5 @@ +log-level = "INFO" + +[database] +user = "example-user" +password = "example-password" diff --git a/examples/10_slices/config.toml b/examples/10_slices/config.toml new file mode 100644 index 0000000..b062443 --- /dev/null +++ b/examples/10_slices/config.toml @@ -0,0 +1,3 @@ +allowed-origins = ["https://app.example.com", "https://admin.example.com"] +filters = ["name = 'a,b'", "cloud < 20"] +counts = [2, 5] diff --git a/examples/10_slices/main.go b/examples/10_slices/main.go index 8f3f9c6..c9905c8 100644 --- a/examples/10_slices/main.go +++ b/examples/10_slices/main.go @@ -7,15 +7,18 @@ import ( ) 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"` + AllowedOrigins []string `flag:"allowed-origins" env:"ALLOWED_ORIGINS" toml:"allowed-origins" validate:"omitempty,dive,url" help:"comma-separated list of allowed CORS origins"` + Filters []string `flag:"filter" help:"Filter expression (repeat for multiple filters)" slice:"repeated-flag"` + Counts []int `flag:"counts" default:"3,7" help:"Comma-separated counts"` } // usage: ./app --allowed-origins=https://app.example.com,https://admin.example.com +// or: ./app --filter "name = 'a,b'" --filter "cloud < 20" --counts 2,5 // 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")) + structconf.MustLoad(cfg, "app", structconf.WithVersion("1.0.0"), structconf.WithLoadConfigFlag("load-config"), structconf.WithDisableImplicitEnvVars()) - fmt.Printf("%v\n", cfg.AllowedOrigins) + fmt.Printf("Origins: %q\nFilters: %q\nCounts: %v\n", cfg.AllowedOrigins, cfg.Filters, cfg.Counts) } diff --git a/examples/11_arguments/main.go b/examples/11_arguments/main.go index 1fd1ff1..4eb3094 100644 --- a/examples/11_arguments/main.go +++ b/examples/11_arguments/main.go @@ -9,16 +9,17 @@ import ( 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"` + Name string `arg:"0" help:"Name to greet" nargs:"1"` + Count int `arg:"1" default:"1" validate:"gte=1" help:"Number of times to print the greeting" nargs:"?"` Loud bool `flag:"loud" help:"Print in uppercase"` } -// usage: -// ./arguments Tilebox 3 --greeting Hi --loud +// Run from the repository root: +// go run ./examples/11_arguments --greeting Hi --loud Tilebox 3 +// go run ./examples/11_arguments Tilebox func main() { cfg := &Config{} - structconf.MustLoad(cfg, "arguments") + structconf.MustLoad(cfg, "arguments", structconf.WithDisableImplicitEnvVars()) message := fmt.Sprintf("%s, %s!", cfg.Greeting, cfg.Name) if cfg.Loud { diff --git a/examples/12_updates/main.go b/examples/12_updates/main.go new file mode 100644 index 0000000..745261d --- /dev/null +++ b/examples/12_updates/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/tilebox/structconf" + "github.com/urfave/cli/v3" +) + +type UpdateArgs struct { + Workflow string `arg:"0" validate:"required" help:"Workflow to update" nargs:"1"` + Summary *string `help:"New summary; an empty string clears it"` + SummaryFile *string `help:"Read the summary from a file" file:"true"` + Enabled *bool `help:"Enable or disable the workflow"` +} + +// Run from the repository root: +// go run ./examples/12_updates update --summary "" --enabled=false my-workflow +// go run ./examples/12_updates update --summary-file README.md my-workflow +// This example prints the requested changes without updating a remote service. +func main() { + args := &UpdateArgs{} + cmd := &cli.Command{ + Name: "update", + Usage: "Preview a workflow update", + Action: func(_ context.Context, _ *cli.Command) error { + if args.SummaryFile != nil { + // file:"true" only enables completion; the action owns file I/O. + data, err := os.ReadFile(*args.SummaryFile) + if err != nil { + return fmt.Errorf("read summary: %w", err) + } + summary := string(data) + args.Summary = &summary + } + if args.Summary != nil { + fmt.Printf("Set %s summary to %q\n", args.Workflow, *args.Summary) + } + if args.Enabled != nil { + fmt.Printf("Set %s enabled to %t\n", args.Workflow, *args.Enabled) + } + return nil + }, + } + if err := structconf.BindCommand(cmd, args, + structconf.WithDisableImplicitEnvVars(), + structconf.WithValidator(validateUpdate), + ); err != nil { + panic(err) + } + root := &cli.Command{ + Name: "workflows", + EnableShellCompletion: true, + Commands: []*cli.Command{cmd}, + } + if err := root.Run(context.Background(), os.Args); err != nil { + if _, printErr := fmt.Fprintln(os.Stderr, renderError(err)); printErr != nil { + panic(printErr) + } + os.Exit(1) + } +} + +func validateUpdate(_ *cli.Command, config any) error { + if err := structconf.Validate(config); err != nil { + return err + } + args := config.(*UpdateArgs) + if args.Summary != nil && args.SummaryFile != nil { + return structconf.NewValidationError( + "choose either --summary or --summary-file", args, "Summary", "SummaryFile", + ) + } + if args.Summary == nil && args.SummaryFile == nil && args.Enabled == nil { + return structconf.NewValidationError( + "provide at least one update", args, "Summary", "SummaryFile", "Enabled", + ) + } + return nil +} + +func renderError(err error) string { + var validation *structconf.ValidationError + if !errors.As(err, &validation) { + return err.Error() + } + var lines []string + for _, issue := range validation.Issues { + lines = append(lines, issue.Message) + for _, field := range issue.Fields { + if field.Name != "" { + lines = append(lines, fmt.Sprintf(" %s: %s", field.Name, field.Help)) + } + } + } + return strings.Join(lines, "\n") +} diff --git a/examples/13_forwarding/main.go b/examples/13_forwarding/main.go new file mode 100644 index 0000000..9403d02 --- /dev/null +++ b/examples/13_forwarding/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/tilebox/structconf" + "github.com/urfave/cli/v3" +) + +type RunArgs struct { + Number int `default:"1" validate:"gte=1" help:"Number of workers" alias:"-n"` + Target string `arg:"0" help:"Execution target" nargs:"1"` + Command []string `arg:"1" help:"Command and its arguments" nargs:"+"` +} + +// Run from the repository root: +// go run ./examples/13_forwarding -n 2 local echo --literal-flag "a,b" "" +// This example prints the command without executing it. +func main() { + args := &RunArgs{} + cmd := &cli.Command{ + Name: "run", + Usage: "Preview a command on an execution target", + StopOnNthArg: new(1), // After the target, flags belong to the forwarded command. + Action: func(_ context.Context, _ *cli.Command) error { + fmt.Printf("Target: %s\nWorkers: %d\nCommand: %q\n", args.Target, args.Number, args.Command) + return nil + }, + } + if err := structconf.BindCommand(cmd, args, structconf.WithDisableImplicitEnvVars()); err != nil { + panic(err) + } + if err := cmd.Run(context.Background(), os.Args); err != nil { + if _, printErr := fmt.Fprintln(os.Stderr, err); printErr != nil { + panic(printErr) + } + os.Exit(1) + } +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..df1939f --- /dev/null +++ b/examples/README.md @@ -0,0 +1,138 @@ +# Examples + +Run these commands from the repository root. Each directory contains a standalone program; the examples print results without contacting external services. + +## Basic configuration + +| Example | Shows | Run | +| --- | --- | --- | +| [01_simple_cli](01_simple_cli) | Flags, inferred environment variables, generated help | `go run ./examples/01_simple_cli --greet --name World` | +| [02_default_values](02_default_values) | Defaults and help text | `go run ./examples/02_default_values --greet` | +| [03_nested](03_nested) | Nested field names | `go run ./examples/03_nested --server-host localhost --server-port 8080` | +| [04_toml](04_toml) | TOML configuration with CLI overrides | `go run ./examples/04_toml --load-config examples/04_toml/config.toml --log-level DEBUG` | +| [05_override](05_override) | Custom source names and excluded fields | `go run ./examples/05_override --level DEBUG` | +| [06_global](06_global) | Names without parent prefixes | `go run ./examples/06_global --name Tilebox` | +| [07_marshal](07_marshal) | Maps, slog, and secret redaction | `go run ./examples/07_marshal --database-user demo --database-password example-password` | +| [08_validation](08_validation) | Required fields, ranges, and allowed values | `go run ./examples/08_validation --host localhost --port 8080 --path /tmp` | +| [09_subcommands](09_subcommands) | `NewCommand`, `BindCommand`, and shell completion | `go run ./examples/09_subcommands sum --left 10 --right 5` | + +## Repeated flags and typed slices + +[10_slices](10_slices) combines comma-separated origins and integers with literal repeated filters: + +```bash +go run ./examples/10_slices --filter "name = 'a,b'" --filter "cloud < 20" --counts 2,5 +ALLOWED_ORIGINS=https://app.example.com go run ./examples/10_slices +go run ./examples/10_slices --load-config examples/10_slices/config.toml +``` + +The explicit `env:"ALLOWED_ORIGINS"` binding works with `WithDisableImplicitEnvVars()`. Fields without an explicit environment name do not bind environment variables. + +Comma-separated mode splits on commas without CSV quoting or whitespace trimming. Repeated-flag mode treats each occurrence as one element, including commas and empty strings. Its environment/default source also supplies one literal element; use a TOML array for multiple fallback elements. The first CLI occurrence replaces the entire fallback slice. + +## Positional arguments + +[11_arguments](11_arguments) uses a required name (`nargs:"1"`) and an optional count (`nargs:"?"`) with a default: + +```bash +go run ./examples/11_arguments Tilebox +go run ./examples/11_arguments --greeting Hi --loud Tilebox 3 +``` + +| `nargs` | Values consumed | Field type | +| --- | --- | --- | +| `?` | Zero or one | Scalar or scalar pointer | +| `1` | Exactly one | Scalar, scalar pointer, or slice | +| `2`, `3`, … | Exactly that many | Slice | +| `*` | Zero or more | Slice | +| `+` | One or more | Slice | +| Omitted | Zero or one; slices split that token on commas | Scalar, scalar pointer, or slice | + +Argument indices order fields, not tokens. For example, a pair at `arg:"0"` consumes two tokens before `arg:"1"`: + +```go +type RangeArgs struct { + Bounds []int `arg:"0" nargs:"2"` + Label *string `arg:"1" nargs:"?"` +} +// 10 25 window → Bounds: []int{10, 25}, Label: pointer to "window" +``` + +Explicit `nargs` preserves each token literally. An omitted argument can use fallback sources, but a partially supplied group cannot fill its missing elements from a fallback. The resolved length must satisfy `nargs`. Required fields must precede optional fields, and a variadic (`*` or `+`) field must be last. + +For compatibility, non-pointer arguments without `nargs` retain preinitialized values if no source supplies input. Pointers and explicit `nargs` bindings reset on each parse. Use explicit cardinality for new commands. + +[13_forwarding](13_forwarding) combines a required target with a variadic command. Urfave's `StopOnNthArg: new(1)` stops parsing flags after the target, so flags belonging to the child command remain literal arguments: + +```bash +go run ./examples/13_forwarding -n 2 local echo --literal-flag "a,b" "" +``` + +```text +Target: local +Workers: 2 +Command: ["echo" "--literal-flag" "a,b" ""] +``` + +The example only prints the command. A real runner would pass the argument slice to its execution API without joining it into a shell command. `Load` / `LoadArgs` reject leftovers when positional bindings exist; `BindCommand` / `NewCommand` leave leftovers in `cmd.Args()` for the action to handle. + +## Partial updates and structured errors + +[12_updates](12_updates) uses scalar pointers for partial updates, a file-completion flag, and a custom validator. Omitted fields stay nil; explicitly empty strings and false booleans still request an update: + +```bash +go run ./examples/12_updates update --summary "" --enabled=false my-workflow +go run ./examples/12_updates update --summary-file README.md my-workflow +``` + +```text +Set my-workflow summary to "" +Set my-workflow enabled to false +``` + +The output above is from the first command. The second reads the file in the action: `file:"true"` only supplies completion metadata. Pointer defaults would make an omitted input non-nil, so these update fields deliberately have no defaults. + +Try conflicting inputs to see focused help from `ValidationError.Issues`: + +```bash +go run ./examples/12_updates update --summary text --summary-file README.md my-workflow +``` + +```text +choose either --summary or --summary-file + --summary: New summary; an empty string clears it + --summary-file: Read the summary from a file +``` + +The custom validator calls `structconf.Validate` first to keep tag validation. It then creates errors with `NewValidationError(message, config, paths...)`, using Go field paths such as `SummaryFile` or `Server.Port`. The renderer recognizes wrapped structured errors with `errors.As` and leaves other errors intact. + +For inputs defined outside structconf, construct the same error type directly: + +```go +return &structconf.ValidationError{ + Issues: []structconf.ValidationIssue{{ + Message: "authentication is required", + Fields: []structconf.FieldInfo{{ + Name: "--api-key", + Help: "API key used to authenticate requests", + }}, + }}, +} +``` + +`Describe(config)` exposes field paths, CLI names, and help without loading values; it also accepts typed nil pointers. Use it for command documentation or other metadata consumers. Neither field metadata nor default validation messages include input values. Custom messages and native urfave parsing errors may include them. + +## Shell completion + +The subcommand and update examples enable completion on their root command. For a standalone loader, pass `WithShellCompletions()`. After building your application as `app`, install its completion script: + +```bash +# Bash +source <(app completion bash) + +# Zsh +source <(app completion zsh) + +# Fish +app completion fish > ~/.config/fish/completions/app.fish +``` diff --git a/go.mod b/go.mod index f26f068..b54610b 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,23 @@ module github.com/tilebox/structconf -go 1.25.0 +go 1.26.0 require ( github.com/BurntSushi/toml v1.6.0 - github.com/go-playground/validator/v10 v10.30.2 + github.com/go-playground/validator/v10 v10.30.6 github.com/iancoleman/strcase v0.3.0 github.com/samber/lo v1.53.0 - github.com/stretchr/testify v1.11.1 - github.com/urfave/cli/v3 v3.8.0 + github.com/stretchr/testify v1.12.1 + github.com/urfave/cli/v3 v3.14.0 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.13 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.37.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.16 // indirect + github.com/go-playground/locales v0.14.2 // indirect + github.com/go-playground/universal-translator v0.18.2 // indirect + github.com/leodido/go-urn v1.5.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/sys v0.50.0 // indirect + golang.org/x/text v0.43.0 // indirect ) diff --git a/go.sum b/go.sum index 4efce2a..ef11c12 100644 --- a/go.sum +++ b/go.sum @@ -1,36 +1,30 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= -github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E= +github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= -github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY= +github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU= +github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk= +github.com/go-playground/validator/v10 v10.30.6 h1:E+mymlPNRzAMNz1FShrRvLeGWcprF5w305eLLgWieuo= +github.com/go-playground/validator/v10 v10.30.6/go.mod h1:jPLL8SKCXGe/g87QmEyNw3NO28nmGt5GCnZnufm7rj4= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= -github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/urfave/cli/v3 v3.14.0 h1:a8414NQlHJs0c/iBsulKLzlES0n/lEAskbL2LKpU4/s= +github.com/urfave/cli/v3 v3.14.0/go.mod h1:vXn6HxPNccJSzQr2QvwVncOKrgYGIHU0HY5h8B2nQj4= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs= +golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= diff --git a/marshal.go b/marshal.go index 2a89ecf..55c86a8 100644 --- a/marshal.go +++ b/marshal.go @@ -65,7 +65,7 @@ func marshalStruct(anyStruct any, into map[string]any, nameFromTags func(t *conf if fieldType.Type.Kind() == reflect.Pointer && fieldType.Type.Elem().Kind() == reflect.Struct { if fieldValue.IsNil() { - fieldValue.Set(reflect.New(fieldType.Type.Elem())) + continue } nested := make(map[string]any) into[fieldName] = nested @@ -81,7 +81,7 @@ func marshalStruct(anyStruct any, into map[string]any, nameFromTags func(t *conf continue } - value, err := getFieldValue(fieldType, fieldValue, tags) + value, err := getFieldValue(fieldValue, tags) if err != nil { return err } @@ -91,17 +91,33 @@ func marshalStruct(anyStruct any, into map[string]any, nameFromTags func(t *conf return nil } -func getFieldValue(field reflect.StructField, fieldValue reflect.Value, tags *configFieldTags) (any, error) { - switch field.Type.Kind() { //nolint:exhaustive // we have a default: clause that results in an error +func getFieldValue(fieldValue reflect.Value, tags *configFieldTags) (any, error) { + if fieldValue.Kind() == reflect.Pointer { + if fieldValue.IsNil() { + return nil, nil //nolint:nilnil // A nil pointer has no configured value. + } + return getFieldValue(fieldValue.Elem(), tags) + } + switch fieldValue.Kind() { //nolint:exhaustive // we have a default: clause that results in an error case reflect.String: if tags.isSecret { return redactSecret(fieldValue.String()), nil } return fieldValue.String(), nil + case reflect.Slice: + values := make([]any, fieldValue.Len()) + for i := range values { + value, err := getFieldValue(fieldValue.Index(i), tags) + if err != nil { + return nil, fmt.Errorf("slice element %d: %w", i, err) + } + values[i] = value + } + return values, nil case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32: return fieldValue.Int(), nil case reflect.Int64: - if dur, ok := fieldValue.Interface().(time.Duration); ok { // special handling for time.Duration, which is a int64 + if dur, ok := reflect.TypeAssert[time.Duration](fieldValue); ok { // special handling for time.Duration, which is a int64 return dur.String(), nil } return fieldValue.Int(), nil @@ -112,7 +128,7 @@ func getFieldValue(field reflect.StructField, fieldValue reflect.Value, tags *co case reflect.Bool: return fieldValue.Bool(), nil default: - return nil, fmt.Errorf("unknown field type %s", field.Type.Kind()) + return nil, fmt.Errorf("unknown field type %s", fieldValue.Kind()) } } @@ -123,7 +139,7 @@ func mapToSlogAttrs(m map[string]any) []any { nestedAttrs := mapToSlogAttrs(nested) attrs = append(attrs, slog.Group(key, nestedAttrs...)) } else { - attrs = append(attrs, slog.String(key, fmt.Sprintf("%v", value))) + attrs = append(attrs, slog.Any(key, value)) } } diff --git a/metadata.go b/metadata.go new file mode 100644 index 0000000..d28f92b --- /dev/null +++ b/metadata.go @@ -0,0 +1,91 @@ +package structconf + +import ( + "errors" + "fmt" + "reflect" + "strings" +) + +// FieldInfo identifies a field without including its value. Name is a flag +// ("--name") or positional argument (""); Path is its Go field path. +type FieldInfo struct { + Path string + Name string + Help string +} + +// Describe resolves field names without loading or modifying config. Config may +// be a struct or a pointer to one, including a typed nil pointer. +func Describe(config any) ([]FieldInfo, error) { + t := reflect.TypeOf(config) + if t == nil { + return nil, errors.New("configuration must be a struct") + } + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil, errors.New("configuration must be a struct") + } + return describeStruct(t, nil, make(map[reflect.Type]bool)) +} + +func describeStruct(t reflect.Type, parents []*configFieldTags, ancestors map[reflect.Type]bool) ([]FieldInfo, error) { + if ancestors[t] { + return nil, fmt.Errorf("recursive configuration type %s", t) + } + ancestors[t] = true + defer delete(ancestors, t) + var fields []FieldInfo + for field := range t.Fields() { + if !field.IsExported() { + continue + } + tags := parseTagsWithFieldNameDefault(&field.Tag, field.Name) + nestedType := field.Type + if nestedType.Kind() == reflect.Pointer { + nestedType = nestedType.Elem() + } + if nestedType.Kind() == reflect.Struct { + nested, err := describeStruct(nestedType, append(append([]*configFieldTags{}, parents...), tags), ancestors) + if err != nil { + return nil, err + } + fields = append(fields, nested...) + continue + } + fields = append(fields, describeField(tags, parents)) + } + return fields, nil +} + +func bindingName(tags *configFieldTags, parents []*configFieldTags) string { + name := tags.flag + if tags.arg != "" { + name = tags.toml + } + if tags.isGlobal { + return name + } + parts := make([]string, 0, len(parents)+1) + for _, parent := range parents { + parts = append(parts, parent.flag) + } + return strings.Join(append(parts, name), "-") +} + +func describeField(tags *configFieldTags, parents []*configFieldTags) FieldInfo { + info := FieldInfo{Help: tags.help} + path := make([]string, 0, len(parents)+1) + for _, parent := range parents { + path = append(path, parent.fieldName) + } + info.Path = strings.Join(append(path, tags.fieldName), ".") + if tags.arg != "" { + info.Name = "<" + bindingName(tags, parents) + ">" + } else if tags.flag != "-" { + info.Name = "--" + bindingName(tags, parents) + } + return info +} diff --git a/slice.go b/slice.go new file mode 100644 index 0000000..5243405 --- /dev/null +++ b/slice.go @@ -0,0 +1,166 @@ +package structconf + +import ( + "fmt" + "reflect" + "strings" + "time" + + "github.com/urfave/cli/v3" +) + +// sliceFlag keeps the normal urfave flag lifecycle, but selects splitting per +// field instead of using the command-wide slice separator setting. +type sliceFlag struct { + *cli.StringFlag + + field reflect.StructField + defaults reflect.Value + value reflect.Value + repeated bool + set bool + fromSource bool + info FieldInfo + secret bool + count int + stringer cli.FlagStringFunc +} + +func (f *sliceFlag) PreParse() error { + f.value = f.defaults + f.set = false + f.fromSource = false + f.count = 0 + return nil +} + +func (f *sliceFlag) Set(_ string, value string) error { + values := splitSliceValue(value, f.repeated) + parsed, err := parseSlice(f.field, values) + if err != nil { + return err + } + if f.repeated && f.set && !f.fromSource { + f.value = reflect.AppendSlice(f.value, parsed) + } else { + f.value = parsed + } + if f.fromSource { + f.count = 0 + } + f.count++ + f.set = true + f.fromSource = false + return nil +} + +func (f *sliceFlag) PostParse() error { + if f.set { + return nil + } + for _, source := range f.Sources.Chain { + values, found := sliceSourceValues(source, f.repeated) + if !found { + continue + } + parsed, err := parseSlice(f.field, values) + if err != nil { + message := "invalid source for --" + f.Name + if !f.secret { + message += ": " + err.Error() + } + return &ValidationError{Issues: []ValidationIssue{{ + Message: message, + Fields: []FieldInfo{f.info}, + }}} + } + f.value, f.set = parsed, true + f.fromSource = true + f.count = 1 + break + } + return nil +} + +func (f *sliceFlag) Get() any { return f.value.Interface() } +func (f *sliceFlag) IsSet() bool { return f.set } +func (f *sliceFlag) Count() int { return f.count } +func (f *sliceFlag) IsMultiValueFlag() bool { return f.repeated } +func (f *sliceFlag) TypeName() string { return f.field.Type.String() } +func (f *sliceFlag) SchemaType() string { return "array" } + +func (f *sliceFlag) String() string { + if f.stringer != nil { + return f.stringer(f) + } + return cli.FlagStringer(f) +} + +func (f *sliceFlag) SetStringer(stringer cli.FlagStringFunc) { + f.stringer = stringer +} + +func (f *sliceFlag) SchemaItemsType() string { + t := f.field.Type.Elem() + if t == reflect.TypeFor[time.Duration]() { + return "duration" + } + switch t.Kind() { //nolint:exhaustive // Only supported scalar types reach this flag. + case reflect.Bool: + return "boolean" + case reflect.String: + return "string" + case reflect.Float32, reflect.Float64: + return "number" + default: + return "integer" + } +} + +func splitSliceValue(value string, literal bool) []string { + if literal { + return []string{value} + } + if value == "" { + return nil + } + return strings.Split(value, ",") +} + +func sliceSourceValues(source cli.ValueSource, literal bool) ([]string, bool) { + if maps, ok := source.(*mapsValueSource); ok { + for _, m := range maps.maps { + value, found := m.Lookup(maps.key) + if !found { + continue + } + v := reflect.ValueOf(value) + if v.IsValid() && (v.Kind() == reflect.Slice || v.Kind() == reflect.Array) { + values := make([]string, v.Len()) + for i := range v.Len() { + values[i] = fmt.Sprint(v.Index(i).Interface()) + } + return values, true + } + return splitSliceValue(fmt.Sprint(value), literal), true + } + return nil, false + } + value, found := source.Lookup() + return splitSliceValue(value, literal), found +} + +func parseSlice(field reflect.StructField, values []string) (reflect.Value, error) { + result := reflect.Zero(field.Type) + if len(values) > 0 { + result = reflect.MakeSlice(field.Type, len(values), len(values)) + } + element := field + element.Type = field.Type.Elem() + for i, value := range values { + if err := setFieldValueFromString(element, result.Index(i), value); err != nil { + return reflect.Value{}, fmt.Errorf("element %d: %w", i, err) + } + } + return result, nil +} diff --git a/structconf.go b/structconf.go index 33b263d..3283bc5 100644 --- a/structconf.go +++ b/structconf.go @@ -23,36 +23,54 @@ type cliOptions struct { } type commandOptions struct { - validator ConfigValidator + validator ConfigValidator + disableImplicitEnvVars bool + skipSources bool // The config-file discovery pass parses CLI flags only. } type ( - Option func(opts *cliOptions) - CommandOption func(opts *commandOptions) + Option interface{ apply(*cliOptions) } + CommandOption interface{ applyCommand(*commandOptions) } + loadOption func(*cliOptions) + commandOption func(*commandOptions) ) +func (o loadOption) apply(opts *cliOptions) { o(opts) } +func (o commandOption) apply(opts *cliOptions) { o(&opts.commandOptions) } +func (o commandOption) applyCommand(opts *commandOptions) { o(opts) } + +// WithDisableImplicitEnvVars disables inferred environment names. Explicit, +// nonempty env tags still bind environment variables. It works with both Load +// and command binding. +func WithDisableImplicitEnvVars() interface { + Option + CommandOption +} { + return commandOption(func(opts *commandOptions) { opts.disableImplicitEnvVars = true }) +} + func WithVersion(version string) Option { - return func(opts *cliOptions) { + return loadOption(func(opts *cliOptions) { opts.version = version - } + }) } func WithDescription(description string) Option { - return func(opts *cliOptions) { + return loadOption(func(opts *cliOptions) { opts.description = description - } + }) } func WithLongDescription(usage string) Option { - return func(opts *cliOptions) { + return loadOption(func(opts *cliOptions) { opts.longDescription = usage - } + }) } func WithShellCompletions() Option { - return func(opts *cliOptions) { + return loadOption(func(opts *cliOptions) { opts.enableShellCompletion = true - } + }) } func WithDefaultLoadConfigFlag() Option { @@ -60,33 +78,37 @@ func WithDefaultLoadConfigFlag() Option { } func WithLoadConfigFlag(flagName string) Option { - return func(opts *cliOptions) { + return loadOption(func(opts *cliOptions) { opts.loadConfigFlagName = flagName - } + }) } -func WithValidator(validator ConfigValidator) Option { - return func(opts *cliOptions) { - opts.commandOptions.validator = validator - } +// WithValidator replaces tag validation for loading or command binding. +func WithValidator(validator ConfigValidator) interface { + Option + CommandOption +} { + return commandOption(func(opts *commandOptions) { + opts.validator = validator + }) } +// WithCommandValidator is the command-only alias of WithValidator. func WithCommandValidator(validator ConfigValidator) CommandOption { - return func(opts *commandOptions) { - opts.validator = validator - } + return WithValidator(validator) } -func WithDisableValidation() Option { - return func(opts *cliOptions) { - opts.commandOptions.validator = func(cmd *cli.Command, configPointer any) error { return nil } - } +// WithDisableValidation disables validation for loading or command binding. +func WithDisableValidation() interface { + Option + CommandOption +} { + return WithValidator(func(*cli.Command, any) error { return nil }) } +// WithDisableCommandValidation is the command-only alias of WithDisableValidation. func WithDisableCommandValidation() CommandOption { - return func(opts *commandOptions) { - opts.validator = func(cmd *cli.Command, configPointer any) error { return nil } - } + return WithDisableValidation() } // MustLoad is like Load, but if it fails, it prints the error to stderr and exits @@ -118,7 +140,7 @@ func MustLoadArgs(configPointer any, programName string, args []string, opts ... // // It loads the config from the following sources in the given order: // 1. command line flags and arguments -// 2. config files (if the config struct satisfies the loadConfigFromTOMLFiles interface by embedding LoadTOMLConfig) +// 2. TOML config files (when WithLoadConfigFlag is enabled) // 3. environment variables // 4. default values defined in the field tags // @@ -129,6 +151,7 @@ func Load(configPointer any, programName string, opts ...Option) error { } // LoadArgs is like Load, but allows explicitly providing the CLI args. +// When positional bindings exist, it rejects any unconsumed arguments. func LoadArgs(configPointer any, programName string, args []string, opts ...Option) error { cfg := &cliOptions{ commandOptions: commandOptions{ @@ -136,7 +159,7 @@ func LoadArgs(configPointer any, programName string, args []string, opts ...Opti }, } for _, opt := range opts { - opt(cfg) + opt.apply(cfg) } tomlSources := make([]cli.MapSource, 0) @@ -148,7 +171,9 @@ func LoadArgs(configPointer any, programName string, args []string, opts ...Opti Usage: "Load configuration from TOML files", } - config, err := NewStructConfigurator(configPointer, nil) + discoveryOptions := cfg.commandOptions + discoveryOptions.skipSources = true + config, err := newStructConfigurator(configPointer, nil, discoveryOptions) if err != nil { return err } @@ -187,26 +212,27 @@ func LoadArgs(configPointer any, programName string, args []string, opts ...Opti err = cmd.Run(context.Background(), args) if err != nil { if stdout.Len() > 0 { - return errors.New(err.Error() + "\n\n" + stdout.String()) + return fmt.Errorf("%w\n\n%s", err, stdout.String()) } return err } - if stdout.Len() > 0 { // help was requested -> return an error so that we can exit - return &helpRequestedError{ - helpText: stdout.String(), - } - } + // Help and completion are rendered again by the final command, which + // includes positional arguments and all source metadata. } - config, err := NewStructConfigurator(configPointer, tomlSources) + config, err := newStructConfigurator(configPointer, tomlSources, cfg.commandOptions) if err != nil { return err } flags := config.Flags() if loadConfigFlag != nil { - flags = append(flags, loadConfigFlag) + // Do not reuse the discovery flag's parsed values or occurrence count. + flags = append(flags, &cli.StringSliceFlag{ + Name: cfg.loadConfigFlagName, + Usage: "Load configuration from TOML files", + }) } if duplicate := firstDuplicateFlagName(flags); duplicate != "" { @@ -228,6 +254,11 @@ func LoadArgs(configPointer any, programName string, args []string, opts ...Opti Flags: flags, Arguments: config.Arguments(), Action: func(ctx context.Context, cmd *cli.Command) error { + if len(cmd.Arguments) > 0 && cmd.NArg() > 0 { + return &ValidationError{Issues: []ValidationIssue{{ + Message: fmt.Sprintf("unexpected positional arguments: got %d extra", cmd.NArg()), + }}} + } config.Apply(cmd) return nil }, @@ -236,7 +267,7 @@ func LoadArgs(configPointer any, programName string, args []string, opts ...Opti err = cmd.Run(context.Background(), args) if err != nil { if stdout.Len() > 0 { - return errors.New(strings.TrimSpace(err.Error() + "\n\n" + stdout.String())) + return fmt.Errorf("%w\n\n%s", err, strings.TrimSpace(stdout.String())) } return err } @@ -274,6 +305,7 @@ func NewCommand(configPointer any, commandName string, action cli.ActionFunc, op // // 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. +// Unconsumed arguments remain available to the action through cmd.Args(). // // The WithLoadConfigFlag option is not currently supported for BindCommand/NewCommand. func BindCommand(command *cli.Command, configPointer any, opts ...CommandOption) error { @@ -281,10 +313,10 @@ func BindCommand(command *cli.Command, configPointer any, opts ...CommandOption) validator: validate, // default validator } for _, opt := range opts { - opt(cfg) + opt.applyCommand(cfg) } - config, err := NewStructConfigurator(configPointer, nil) + config, err := newStructConfigurator(configPointer, nil, *cfg) if err != nil { return err } diff --git a/structconf_test.go b/structconf_test.go index b6ae331..a88fd6f 100644 --- a/structconf_test.go +++ b/structconf_test.go @@ -3,8 +3,11 @@ package structconf import ( "context" "errors" + "fmt" + "log/slog" "os" "path" + "reflect" "slices" "strings" "testing" @@ -20,12 +23,12 @@ func Test_LoadArgsFullyTagged(t *testing.T) { Value string `flag:"value" env:"VALUE" default:"value-from-default-tag" toml:"value"` Nested struct { Value string `flag:"value" env:"VALUE" default:"nested-value-from-default-tag" toml:"value"` - } `flag:"nested" toml:"nested" env:"NESTED"` + } `flag:"nested" env:"NESTED" toml:"nested"` Doubly struct { Nested struct { Value string `flag:"value" env:"VALUE" default:"double-nested-value-from-default-tag" toml:"value"` } `flag:"nested" env:"NESTED" toml:"nested"` - } `flag:"doubly" toml:"doubly" env:"DOUBLY"` + } `flag:"doubly" env:"DOUBLY" toml:"doubly"` Duration time.Duration `flag:"duration" env:"DURATION" default:"10s" toml:"duration"` } @@ -185,6 +188,574 @@ func Test_LoadArgsStringSlice(t *testing.T) { } } +func TestSliceModesAndSources(t *testing.T) { + type config struct { + Filters []string `flag:"filter" env:"FILTERS" default:"default,filter" toml:"filters" slice:"repeated-flag"` + Counts []int `env:"COUNTS" default:"3,7" slice:"comma-separated"` + } + for _, tc := range []struct { + name string + args []string + toml string + env bool + filters []string + counts []int + }{ + {"defaults", nil, "", false, []string{"default,filter"}, []int{3, 7}}, + {"environment", nil, "", true, []string{"env,filter"}, []int{11, 13}}, + {"toml overrides env", nil, `filters = ["a,b", "c"]` + "\ncounts = [17, 19]", true, []string{"a,b", "c"}, []int{17, 19}}, + {"cli replaces fallbacks", []string{"--filter", "x,y", "--filter", "", "--counts", "23,29", "--counts", "31,37"}, `filters = ["toml"]` + "\ncounts = [41]", true, []string{"x,y", ""}, []int{31, 37}}, + {"empty arrays override", nil, "filters = []\ncounts = []", true, nil, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.env { + t.Setenv("FILTERS", "env,filter") + t.Setenv("COUNTS", "11,13") + } + args := append([]string{"app"}, tc.args...) + if tc.toml != "" { + file := path.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(file, []byte(tc.toml), 0o600)) + args = append(args, "--load-config", file) + } + var cfg config + require.NoError(t, LoadArgs(&cfg, "app", args, WithDefaultLoadConfigFlag())) + assert.Equal(t, tc.filters, cfg.Filters) + assert.Equal(t, tc.counts, cfg.Counts) + }) + } +} + +func TestPrimitiveSlices(t *testing.T) { + for _, tc := range []struct { + want any + values []string + invalid string + }{ + {[]string{"a,b", " c "}, []string{"a,b", " c "}, ""}, + {[]int{-3, 17}, []string{"-3", "17"}, "x"}, + {[]int8{-128, 127}, []string{"-128", "127"}, "128"}, + {[]int16{-32768, 32767}, []string{"-32768", "32767"}, "32768"}, + {[]int32{-2147483648, 2147483647}, []string{"-2147483648", "2147483647"}, "2147483648"}, + {[]int64{-9223372036854775808, 9223372036854775807}, []string{"-9223372036854775808", "9223372036854775807"}, "9223372036854775808"}, + {[]uint{0, 17}, []string{"0", "17"}, "-1"}, + {[]uint8{0, 255}, []string{"0", "255"}, "256"}, + {[]uint16{0, 65535}, []string{"0", "65535"}, "65536"}, + {[]uint32{0, 4294967295}, []string{"0", "4294967295"}, "4294967296"}, + {[]uint64{0, 18446744073709551615}, []string{"0", "18446744073709551615"}, "18446744073709551616"}, + {[]float32{-1.25, 2.5}, []string{"-1.25", "2.5"}, "1e100"}, + {[]float64{-1.25, 2.5}, []string{"-1.25", "2.5"}, "1e1000"}, + {[]bool{false, true}, []string{"false", "true"}, "maybe"}, + {[]time.Duration{-time.Second, 1500 * time.Millisecond}, []string{"-1s", "1.5s"}, "1day"}, + } { + t.Run(reflect.TypeOf(tc.want).String(), func(t *testing.T) { + // Every supported scalar also binds through a pointer, including named + // types such as time.Duration. Omission must not allocate it. + element := reflect.TypeOf(tc.want).Elem() + for _, binding := range []string{`flag:"value"`, `arg:"0"`} { + pointerType := reflect.StructOf([]reflect.StructField{{Name: "Value", Type: reflect.PointerTo(element), Tag: reflect.StructTag(binding + ` env:"-"`)}}) + cfg := reflect.New(pointerType) + require.NoError(t, LoadArgs(cfg.Interface(), "app", []string{"app"})) + assert.True(t, cfg.Elem().Field(0).IsNil()) + args := []string{"app"} + if strings.HasPrefix(binding, "flag") { + args = append(args, "--value="+tc.values[0]) + } else { + args = append(args, "--", tc.values[0]) + } + require.NoError(t, LoadArgs(cfg.Interface(), "app", args)) + require.False(t, cfg.Elem().Field(0).IsNil()) + assert.Equal(t, reflect.ValueOf(tc.want).Index(0).Interface(), cfg.Elem().Field(0).Elem().Interface()) + } + for _, mode := range []string{"comma-separated", "repeated-flag"} { + if _, ok := tc.want.([]string); ok && mode == "comma-separated" { + continue + } + typeOf := reflect.StructOf([]reflect.StructField{{Name: "Values", Type: reflect.TypeOf(tc.want), Tag: reflect.StructTag(`env:"-" slice:"` + mode + `"`)}}) + cfg := reflect.New(typeOf) + args := []string{"app"} + if mode == "comma-separated" { + args = append(args, "--values", strings.Join(tc.values, ",")) + } else { + for _, value := range tc.values { + args = append(args, "--values", value) + } + } + require.NoError(t, LoadArgs(cfg.Interface(), "app", args)) + assert.Equal(t, tc.want, cfg.Elem().Field(0).Interface()) + if tc.invalid != "" { + require.Error(t, LoadArgs(reflect.New(typeOf).Interface(), "app", []string{"app", "--values", tc.invalid})) + } + } + }) + } +} + +func TestOptionalScalars(t *testing.T) { + type config struct { + Name *string `arg:"0" env:"-" nargs:"?"` + Note *string `env:"-"` + Enabled *bool `env:"-"` + Count *int `env:"-"` + Wait *time.Duration `env:"-"` + Default *string `env:"-" default:""` + Token *string `env:"TOKEN" secret:"true"` + } + var absent config + require.NoError(t, LoadArgs(&absent, "app", []string{"app"}, WithDisableImplicitEnvVars())) + assert.Nil(t, absent.Name) + assert.Nil(t, absent.Note) + assert.Nil(t, absent.Enabled) + assert.Nil(t, absent.Count) + assert.Nil(t, absent.Wait) + require.NotNil(t, absent.Default) + assert.Empty(t, *absent.Default) + values, err := MarshalAsMap(&absent) + require.NoError(t, err) + assert.NotContains(t, values, "count") + assert.Contains(t, values, "default") + t.Setenv("TOKEN", "private-secret") + var cfg config + require.NoError(t, LoadArgs(&cfg, "app", []string{"app", "--note=", "--enabled=false", "--count=0", "--wait=0s", ""})) + require.NotNil(t, cfg.Name) + assert.Empty(t, *cfg.Name) + require.NotNil(t, cfg.Note) + assert.Empty(t, *cfg.Note) + require.NotNil(t, cfg.Enabled) + assert.False(t, *cfg.Enabled) + require.NotNil(t, cfg.Count) + assert.Zero(t, *cfg.Count) + require.NotNil(t, cfg.Wait) + assert.Zero(t, *cfg.Wait) + values, err = MarshalAsMap(&cfg) + require.NoError(t, err) + assert.Equal(t, "pr***et", values["token"]) + assert.Equal(t, false, values["enabled"]) + assert.Equal(t, int64(0), values["count"]) + file := path.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(file, []byte("count = 17\nenabled = false\nnote = ''\n"), 0o600)) + require.NoError(t, LoadArgs(&cfg, "app", []string{"app", "--load-config", file}, WithDefaultLoadConfigFlag())) + require.NotNil(t, cfg.Count) + assert.Equal(t, 17, *cfg.Count) + require.NotNil(t, cfg.Enabled) + assert.False(t, *cfg.Enabled) + require.NotNil(t, cfg.Note) + assert.Empty(t, *cfg.Note) + assert.Nil(t, cfg.Name) +} + +func TestPointerArgumentGetters(t *testing.T) { + for _, tc := range []struct { + name string + args []string + wantName string + wantCount int + }{ + {"omitted", nil, "", 0}, + {"provided", []string{"hello", "42"}, "hello", 42}, + {"explicit zero", []string{"", "0"}, "", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := &struct { + Name *string `arg:"0" env:"-" nargs:"?"` + Count *int `arg:"1" env:"-" nargs:"?"` + }{Name: new("previous"), Count: new(99)} + cmd, err := NewCommand(cfg, "app", func(_ context.Context, cmd *cli.Command) error { + assert.Equal(t, tc.wantName, cmd.StringArg("name")) + assert.Equal(t, tc.wantCount, cmd.IntArg("count")) + return nil + }) + require.NoError(t, err) + assert.Equal(t, "string", cmd.Arguments[0].(ArgumentMetadata).TypeName()) + assert.Equal(t, "int", cmd.Arguments[1].(ArgumentMetadata).TypeName()) + require.NoError(t, cmd.Run(t.Context(), append([]string{"app"}, tc.args...))) + if len(tc.args) == 0 { + assert.Nil(t, cfg.Name) + assert.Nil(t, cfg.Count) + } else { + require.NotNil(t, cfg.Name) + require.NotNil(t, cfg.Count) + assert.Equal(t, tc.wantName, *cfg.Name) + assert.Equal(t, tc.wantCount, *cfg.Count) + } + }) + } +} + +func TestOptionalFlagMetadata(t *testing.T) { + for _, zero := range []any{"", false, int(0), int8(0), int16(0), int32(0), int64(0), uint(0), uint8(0), uint16(0), uint32(0), uint64(0), float32(0), float64(0), time.Duration(0)} { + t.Run(reflect.TypeOf(zero).String(), func(t *testing.T) { + scalar := reflect.TypeOf(zero) + defaultText := fmt.Sprint(zero) + typ := reflect.StructOf([]reflect.StructField{ + {Name: "Omitted", Type: reflect.PointerTo(scalar), Tag: `env:"-"`}, + {Name: "Default", Type: reflect.PointerTo(scalar), Tag: reflect.StructTag(`env:"-" default:"` + defaultText + `"`)}, + {Name: "Scalar", Type: scalar, Tag: `env:"-"`}, + }) + cfg := reflect.New(typ) + cmd, err := NewCommand(cfg.Interface(), "app", nil) + require.NoError(t, err) + omitted := cmd.Flags[0].(cli.DocGenerationFlag) + assert.False(t, omitted.IsDefaultVisible()) + assert.Empty(t, omitted.GetDefaultText()) + assert.NotContains(t, cmd.Flags[0].String(), "default:") + explicit := cmd.Flags[1].(cli.DocGenerationFlag) + assert.True(t, explicit.IsDefaultVisible()) + assert.Equal(t, defaultText, explicit.GetDefaultText()) + assert.True(t, cmd.Flags[2].(cli.DocGenerationFlag).IsDefaultVisible()) + require.NoError(t, cmd.Run(t.Context(), []string{"app"})) + assert.True(t, cfg.Elem().Field(0).IsNil()) + require.False(t, cfg.Elem().Field(1).IsNil()) + assert.Equal(t, zero, cfg.Elem().Field(1).Elem().Interface()) + }) + } +} + +func TestLegacyArgumentInitialValues(t *testing.T) { + for _, tc := range []struct { + name string + args []string + env bool + wantName string + wantValues []string + }{ + {"omitted", nil, false, "initial", []string{"initial", "values"}}, + {"provided", []string{"cli", "a,b"}, false, "cli", []string{"a", "b"}}, + {"explicit empty", []string{"", ""}, false, "", nil}, + {"source", nil, true, "env", []string{"source", "values"}}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := &struct { + Name string `arg:"0" env:"INITIAL_NAME"` + Values []string `arg:"1" env:"INITIAL_VALUES"` + Optional string `arg:"2" env:"-" nargs:"?"` + Rest []string `arg:"3" env:"-" nargs:"*"` + }{Name: "initial", Values: []string{"initial", "values"}, Optional: "previous", Rest: []string{"previous"}} + if tc.env { + t.Setenv("INITIAL_NAME", "env") + t.Setenv("INITIAL_VALUES", "source,values") + } + require.NoError(t, LoadArgs(cfg, "app", append([]string{"app"}, tc.args...))) + assert.Equal(t, tc.wantName, cfg.Name) + assert.Equal(t, tc.wantValues, cfg.Values) + assert.Empty(t, cfg.Optional) + assert.Nil(t, cfg.Rest) + }) + } +} + +func TestArgumentCardinality(t *testing.T) { + for _, tc := range []struct { + nargs string + args []string + want []string + fail bool + }{ + {"", []string{"a,b"}, []string{"a", "b"}, false}, + {"", []string{"a", "extra"}, nil, true}, + {"1", []string{"a,b"}, []string{"a,b"}, false}, + {"1", nil, nil, true}, + {"2", []string{"a,b", "c"}, []string{"a,b", "c"}, false}, + {"2", []string{"a"}, nil, true}, + {"2", []string{"a", "b", "extra"}, nil, true}, + {"*", nil, nil, false}, + {"*", []string{"a,b", "", "c"}, []string{"a,b", "", "c"}, false}, + {"+", nil, nil, true}, + {"+", []string{""}, []string{""}, false}, + } { + t.Run(tc.nargs+strings.Join(tc.args, "/"), func(t *testing.T) { + typeOf := reflect.StructOf([]reflect.StructField{{Name: "Values", Type: reflect.TypeFor[[]string](), Tag: reflect.StructTag(`arg:"0" env:"-" nargs:"` + tc.nargs + `"`)}}) + cfg := reflect.New(typeOf) + err := LoadArgs(cfg.Interface(), "app", append([]string{"app"}, tc.args...)) + if tc.fail { + var validation *ValidationError + require.ErrorAs(t, err, &validation) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, cfg.Elem().Field(0).Interface()) + }) + } + var cfg struct { + Pair []int `arg:"0" env:"-" nargs:"2"` + Output string `arg:"1" env:"-" nargs:"1"` + } + require.NoError(t, LoadArgs(&cfg, "app", []string{"app", "3", "17", "output"})) + assert.Equal(t, []int{3, 17}, cfg.Pair) + assert.Equal(t, "output", cfg.Output) + err := LoadArgs(&cfg, "app", []string{"app", "3", "17", "output", "extra"}, WithDefaultLoadConfigFlag()) + var validation *ValidationError + require.ErrorAs(t, err, &validation) + assert.Contains(t, err.Error(), "1 extra") + assert.NotContains(t, err.Error(), "output") + var flagsOnly struct{ Name string } + require.NoError(t, LoadArgs(&flagsOnly, "app", []string{"app", "unbound"})) +} + +func TestArgumentSourcesAndPassthrough(t *testing.T) { + type config struct { + Pair []int `arg:"0" env:"PAIR" default:"3,7" nargs:"2"` + } + t.Setenv("PAIR", "11,17") + for _, tc := range []struct { + args []string + want []int + fail bool + }{ + {nil, []int{11, 17}, false}, + {[]string{"23"}, nil, true}, + {[]string{"23", "29"}, []int{23, 29}, false}, + } { + var cfg config + err := LoadArgs(&cfg, "app", append([]string{"app"}, tc.args...)) + if tc.fail { + require.Error(t, err) + continue + } + require.NoError(t, err) + assert.Equal(t, tc.want, cfg.Pair) + } + file := path.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(file, []byte("pair = [31, 37]"), 0o600)) + var cfg config + require.NoError(t, LoadArgs(&cfg, "app", []string{"app", "--load-config", file}, WithDefaultLoadConfigFlag())) + assert.Equal(t, []int{31, 37}, cfg.Pair) + var pass struct { + Command []string `arg:"0" env:"-" nargs:"+"` + } + cmd, err := NewCommand(&pass, "parallel", nil) + require.NoError(t, err) + cmd.StopOnNthArg = new(1) + require.NoError(t, cmd.Run(t.Context(), []string{"parallel", "--", "echo", "--not-a-tilebox-flag", "a,b", ""})) + assert.Equal(t, []string{"echo", "--not-a-tilebox-flag", "a,b", ""}, pass.Command) + var rest []string + cmd, err = NewCommand(&cfg, "app", func(_ context.Context, cmd *cli.Command) error { + rest = cmd.Args().Slice() + return nil + }) + require.NoError(t, err) + require.NoError(t, cmd.Run(t.Context(), []string{"app", "3", "17", "forwarded"})) + assert.Equal(t, []int{3, 17}, cfg.Pair) + assert.Equal(t, []string{"forwarded"}, rest) +} + +func TestRepeatedFlagInheritance(t *testing.T) { + type config struct { + Values []string `env:"VALUES" slice:"repeated-flag"` + } + t.Setenv("VALUES", "fallback,one") + for _, tc := range []struct { + name string + args []string + want []string + count int + }{ + {"fallback", []string{"app", "child"}, []string{"fallback,one"}, 1}, + {"child replaces fallback", []string{"app", "child", "--values", "a,b", "--values", "c"}, []string{"a,b", "c"}, 2}, + {"parent and child", []string{"app", "--values", "parent", "child", "--values", "child"}, []string{"parent", "child"}, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + var cfg config + root, err := NewCommand(&cfg, "app", nil) + require.NoError(t, err) + var got []string + var count int + root.Commands = []*cli.Command{{Name: "child", Action: func(_ context.Context, cmd *cli.Command) error { + got, count = cmd.StringSlice("values"), cmd.Count("values") + return nil + }}} + require.NoError(t, root.Run(t.Context(), tc.args)) + assert.Equal(t, tc.want, got) + assert.Equal(t, tc.count, count) + }) + } +} + +func TestSliceFlagInterfaces(t *testing.T) { + var cfg struct { + Values []int `env:"-" default:"3,7"` + } + var count int + cmd, err := NewCommand(&cfg, "app", func(_ context.Context, cmd *cli.Command) error { + count = cmd.Count("values") + return nil + }) + require.NoError(t, err) + flag := cmd.Flags[0] + stringer := flag.(cli.StringerSetter) + original := flag.String() + stringer.SetStringer(func(f cli.Flag) string { return "custom " + f.Names()[0] }) + assert.Equal(t, "custom values", flag.String()) + stringer.SetStringer(nil) + assert.Equal(t, original, flag.String()) + require.NoError(t, cmd.Run(t.Context(), []string{"app", "--values", "11,13", "--values", "17,19"})) + assert.Equal(t, []int{17, 19}, cfg.Values) + assert.Equal(t, 2, count) +} + +func TestInvalidBindingTags(t *testing.T) { + for _, cfg := range []any{ + &struct { + X []string `slice:"csv"` + }{}, + &struct { + X int `slice:"repeated-flag"` + }{}, + &struct { + X []string `arg:"0" slice:"repeated-flag"` + }{}, + &struct { + X string `nargs:"1"` + }{}, + &struct { + X []string `arg:"0" nargs:"0"` + }{}, + &struct { + X []string `arg:"0" nargs:"-1"` + }{}, + &struct { + X string `arg:"0" nargs:"2"` + }{}, + &struct { + X []string `arg:"0" nargs:"?"` + }{}, + &struct { + X int `file:"true"` + }{}, + &struct { + X string `file:"yes"` + }{}, + &struct { + X []string `arg:"0" nargs:"*"` + Y string `arg:"1"` + }{}, + &struct { + X *string `arg:"0" nargs:"?"` + Y string `arg:"1" nargs:"1"` + }{}, + &struct { + X string `arg:"0" validate:"required"` + Y string `arg:"1" nargs:"1"` + }{}, + &struct{ X [][]string }{}, + &struct{ X **string }{}, + } { + _, err := NewCommand(cfg, "app", nil) + require.Error(t, err, "%T", cfg) + } +} + +func TestExplicitEnvironmentAndFileCompletion(t *testing.T) { + type config struct { + Implicit string + Empty string `env:""` + Explicit string `env:"BOUND"` + File *string `env:"-" file:"true"` + Positional *string `arg:"0"` + } + t.Setenv("IMPLICIT", "implicit") + t.Setenv("EMPTY", "empty") + t.Setenv("BOUND", "explicit") + t.Setenv("POSITIONAL", "positional") + var cfg config + require.NoError(t, LoadArgs(&cfg, "app", []string{"app"})) + assert.Equal(t, "implicit", cfg.Implicit) + require.NotNil(t, cfg.Positional) + for _, loadConfig := range []bool{false, true} { + var cfg config + opts := []Option{WithDisableImplicitEnvVars()} + if loadConfig { + opts = append(opts, WithDefaultLoadConfigFlag()) + } + require.NoError(t, LoadArgs(&cfg, "app", []string{"app"}, opts...)) + assert.Empty(t, cfg.Implicit) + assert.Empty(t, cfg.Empty) + assert.Nil(t, cfg.Positional) + assert.Equal(t, "explicit", cfg.Explicit) + } + var bound config + cmd, err := NewCommand(&bound, "app", nil, WithDisableImplicitEnvVars()) + require.NoError(t, err) + var file *cli.StringFlag + for _, flag := range cmd.Flags { + if flag.Names()[0] == "file" { + file = flag.(*cli.StringFlag) + } + } + require.NotNil(t, file) + assert.True(t, file.TakesFile) + require.NoError(t, cmd.Run(t.Context(), []string{"app", "--file", "input.txt"})) + assert.Empty(t, bound.Implicit) + assert.Equal(t, "explicit", bound.Explicit) + require.NotNil(t, bound.File) + assert.Equal(t, "input.txt", *bound.File) +} + +func TestStructuredValidation(t *testing.T) { + type nested struct { + Token string `flag:"credential" secret:"true" validate:"oneof=allowed" help:"authentication token"` + } + cfg := &struct { + Nested *nested `flag:"auth"` + }{} + fields, err := Describe(cfg) + require.NoError(t, err) + assert.Nil(t, cfg.Nested) + assert.Equal(t, []FieldInfo{{Path: "Nested.Token", Name: "--auth-credential", Help: "authentication token"}}, fields) + cfg.Nested = &nested{Token: "private-secret"} + err = Validate(cfg) + var validation *ValidationError + require.ErrorAs(t, err, &validation) + assert.Equal(t, fields, validation.Issues[0].Fields) + assert.Equal(t, "oneof", validation.Issues[0].Rule) + assert.Equal(t, "allowed", validation.Issues[0].Param) + assert.Equal(t, "--auth-credential must be one of: allowed", validation.Issues[0].Message) + assert.NotContains(t, err.Error(), "private-secret") + custom := NewValidationError("custom failure", cfg, "Nested.Token") + called := false + cmd, err := NewCommand(cfg, "app", func(context.Context, *cli.Command) error { called = true; return nil }, WithCommandValidator(func(*cli.Command, any) error { return custom })) + require.NoError(t, err) + err = cmd.Run(t.Context(), []string{"app"}) + require.ErrorIs(t, err, custom) + assert.False(t, called) + for _, option := range []CommandOption{WithDisableValidation(), WithDisableCommandValidation()} { + cmd, err = NewCommand(cfg, "app", nil, option) + require.NoError(t, err) + require.NoError(t, cmd.Run(t.Context(), []string{"app"})) + } + type Shared struct { + Names []string `flag:"name" validate:"dive,required" global:"true" help:"names to select"` + } + embedded := &struct{ Shared }{Shared: Shared{Names: []string{"ok", ""}}} + err = Validate(embedded) + require.ErrorAs(t, err, &validation) + assert.Equal(t, FieldInfo{Path: "Shared.Names[1]", Name: "--name", Help: "names to select"}, validation.Issues[0].Fields[0]) + custom = NewValidationError("conflict", embedded, "Names") + assert.Equal(t, "--name", custom.Issues[0].Fields[0].Name) + for _, invalidPath := range []string{"Nested.Token[0]", "Nested[0].Token", "Missing"} { + custom = NewValidationError("invalid", cfg, invalidPath) + assert.Equal(t, []FieldInfo{{Path: invalidPath}}, custom.Issues[0].Fields) + } + for _, invalidPath := range []string{"Names[-1]", "Names[", "Names[]", "Names[one]", "Names[0]extra", "Names[0][1]", "Names[+1]"} { + custom = NewValidationError("invalid", embedded, invalidPath) + assert.Equal(t, []FieldInfo{{Path: invalidPath}}, custom.Issues[0].Fields) + } + custom = NewValidationError("invalid", embedded, "Names[0]") + assert.Equal(t, FieldInfo{Path: "Names[0]", Name: "--name", Help: "names to select"}, custom.Issues[0].Fields[0]) + for _, secret := range []string{"false", "true"} { + t.Setenv("COUNTS", "1,private-invalid-value") + typeOf := reflect.StructOf([]reflect.StructField{{Name: "Counts", Type: reflect.TypeFor[[]int](), Tag: reflect.StructTag(`env:"COUNTS" secret:"` + secret + `"`)}}) + err = LoadArgs(reflect.New(typeOf).Interface(), "app", []string{"app"}) + require.ErrorAs(t, err, &validation) + assert.Equal(t, "--counts", validation.Issues[0].Fields[0].Name) + if secret == "true" { + assert.NotContains(t, err.Error(), "private-invalid-value") + } else { + assert.Contains(t, err.Error(), "element 1") + } + } +} + func Test_LoadArgsPrecedence(t *testing.T) { type config struct { Value string `default:"value-from-default-tag"` @@ -291,6 +862,41 @@ duration = "1m5s" } } +func TestLoadConfigDiscovery(t *testing.T) { + type config struct { + Count int `env:"DISCOVERY_COUNT"` + Counts []int `env:"DISCOVERY_COUNTS"` + Target string `arg:"0" env:"-" nargs:"1"` + } + file := path.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(file, []byte("count = 17\ncounts = [3,7]\ntarget = 'toml'\n"), 0o600)) + for _, env := range []string{"DISCOVERY_COUNT", "DISCOVERY_COUNTS"} { + t.Run(env, func(t *testing.T) { + t.Setenv(env, "invalid") + var cfg config + err := LoadArgs(&cfg, "app", []string{"app", "--load-config", file}, WithDefaultLoadConfigFlag(), WithValidator(func(cmd *cli.Command, _ any) error { + assert.Equal(t, []string{file}, cmd.StringSlice("load-config")) + assert.Equal(t, 1, cmd.Count("load-config")) + return nil + })) + require.NoError(t, err) + assert.Equal(t, config{Count: 17, Counts: []int{3, 7}, Target: "toml"}, cfg) + require.Error(t, LoadArgs(&cfg, "app", []string{"app", "target"}, WithDefaultLoadConfigFlag())) + err = LoadArgs(&cfg, "app", []string{"app", "--load-config", file, "--count", "23", "--counts", "11,13", "cli"}, WithDefaultLoadConfigFlag()) + require.NoError(t, err) + assert.Equal(t, config{Count: 23, Counts: []int{11, 13}, Target: "cli"}, cfg) + }) + } + var cfg config + err := LoadArgs(&cfg, "app", []string{"app", "--help"}, WithDefaultLoadConfigFlag()) + var help *helpRequestedError + require.ErrorAs(t, err, &help) + assert.Contains(t, help.Error(), "[arguments...]") + assert.Contains(t, help.Error(), "DISCOVERY_COUNT") + assert.Contains(t, help.Error(), "--load-config") + assert.Empty(t, cfg.Target) +} + func Test_LoadArgsMultipleTomlFilesPrecedence(t *testing.T) { type config struct { Value string @@ -541,6 +1147,92 @@ func Test_LoadArgsArgumentFallbacks(t *testing.T) { } } +func Test_LoadArgsArgumentEmptySources(t *testing.T) { + type config struct { + Name string `arg:"0" env:"TEST_ARG_NAME" default:"default-name"` + Count int `arg:"1" env:"TEST_ARG_COUNT" default:"3"` + Limit uint `arg:"2" env:"TEST_ARG_LIMIT" default:"5"` + Ratio float64 `arg:"3" env:"TEST_ARG_RATIO" default:"1.5"` + Timeout time.Duration `arg:"4" env:"TEST_ARG_TIMEOUT" default:"4s"` + Enabled bool `arg:"5" env:"TEST_ARG_ENABLED" default:"true"` + Values []string `arg:"6" env:"TEST_ARG_VALUES" default:"one,two"` + } + + tests := []struct { + name string + toml string + env map[string]string + args []string + want config + wantError string + }{ + { + name: "empty env uses numeric defaults but clears strings slices and bools", + want: config{Count: 3, Limit: 5, Ratio: 1.5, Timeout: 4 * time.Second}, + }, + { + name: "empty toml falls through to env only for numeric types", + toml: "name = \"\"\ncount = \"\"\nlimit = \"\"\nratio = \"\"\ntimeout = \"\"\nenabled = \"\"\nvalues = []", + env: map[string]string{ + "TEST_ARG_NAME": "env-name", "TEST_ARG_COUNT": "7", "TEST_ARG_LIMIT": "11", + "TEST_ARG_RATIO": "2.5", "TEST_ARG_TIMEOUT": "9s", "TEST_ARG_ENABLED": "true", "TEST_ARG_VALUES": "env-value", + }, + want: config{Count: 7, Limit: 11, Ratio: 2.5, Timeout: 9 * time.Second}, + }, + { + name: "empty toml and env fall through to numeric defaults", + toml: "count = \"\"\nlimit = \"\"\nratio = \"\"\ntimeout = \"\"", + want: config{Count: 3, Limit: 5, Ratio: 1.5, Timeout: 4 * time.Second}, + }, + { + name: "explicit empty string does not fall back", + args: []string{""}, + env: map[string]string{"TEST_ARG_NAME": "env-name"}, + want: config{Count: 3, Limit: 5, Ratio: 1.5, Timeout: 4 * time.Second}, + }, + { + name: "explicit empty number is an error", + args: []string{"name", ""}, + env: map[string]string{"TEST_ARG_COUNT": "7"}, + wantError: "invalid value \"\" for argument count", + }, + { + name: "invalid nonempty toml does not fall back", + toml: "count = \"invalid\"", + env: map[string]string{"TEST_ARG_COUNT": "7"}, + wantError: "failed to parse source value invalid for argument count", + }, + { + name: "whitespace env is not treated as empty", + env: map[string]string{"TEST_ARG_COUNT": " "}, + wantError: "failed to parse source value for argument count", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, key := range []string{"TEST_ARG_NAME", "TEST_ARG_COUNT", "TEST_ARG_LIMIT", "TEST_ARG_RATIO", "TEST_ARG_TIMEOUT", "TEST_ARG_ENABLED", "TEST_ARG_VALUES"} { + t.Setenv(key, tt.env[key]) + } + args := []string{"app"} + if tt.toml != "" { + configPath := path.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(tt.toml), 0o600)) + args = append(args, "--load-config", configPath) + } + args = append(args, tt.args...) + cfg := &config{} + err := LoadArgs(cfg, "app", args, WithDefaultLoadConfigFlag()) + if tt.wantError != "" { + require.ErrorContains(t, err, tt.wantError) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, *cfg) + }) + } +} + func Test_LoadArgsArgumentValidation(t *testing.T) { type config struct { Name string `arg:"0" validate:"required"` @@ -549,7 +1241,7 @@ func Test_LoadArgsArgumentValidation(t *testing.T) { cfg := &config{} err := LoadArgs(cfg, "my-program", []string{"my-program"}) require.Error(t, err) - assert.Contains(t, err.Error(), "Missing required configuration") + assert.EqualError(t, err, " is required") } func Test_LoadArgsArgumentErrors(t *testing.T) { @@ -738,8 +1430,7 @@ func Test_BindCommandValidatesBeforeAction(t *testing.T) { require.NoError(t, err) err = cmd.Run(context.Background(), []string{"greet"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "Missing required configuration") + require.EqualError(t, err, "--name is required") assert.False(t, actionRan) } @@ -759,7 +1450,7 @@ func Test_BindCommandUsesCustomValidator(t *testing.T) { }, } - err := BindCommand(cmd, cfg, WithCommandValidator(func(cmd *cli.Command, configPointer any) error { + err := BindCommand(cmd, cfg, WithValidator(func(cmd *cli.Command, configPointer any) error { assert.Equal(t, "greet", cmd.Name) assert.Same(t, cfg, configPointer) assert.Equal(t, "Tilebox", cfg.Name) @@ -772,6 +1463,192 @@ func Test_BindCommandUsesCustomValidator(t *testing.T) { assert.False(t, actionRan) } +func TestMarshalPreservesNestedPointers(t *testing.T) { + type nested struct{ Enabled bool } + cfg := &struct { + Absent *nested + Present *nested + }{Present: &nested{}} + asMap, err := MarshalAsMap(cfg) + require.NoError(t, err) + assert.Nil(t, cfg.Absent) + assert.Equal(t, map[string]any{"present": map[string]any{"enabled": false}}, asMap) + attr, err := MarshalAsSlogDict(cfg, "config") + require.NoError(t, err) + assert.Nil(t, cfg.Absent) + assert.Equal(t, slog.Group("config", slog.Group("present", slog.Bool("enabled", false))), attr) +} + +func Test_MarshalLoadedStringSlice(t *testing.T) { + cfg := &struct { + EnvironmentEncryption struct { + KeyFiles []string + } + }{} + require.NoError(t, LoadArgs(cfg, "test", []string{ + "test", "--environment-encryption-key-files=old=/tmp/old.key,new=/tmp/new.key", + })) + + asMap, err := MarshalAsMap(cfg) + require.NoError(t, err) + assert.Equal(t, map[string]any{ + "environment-encryption": map[string]any{ + "key-files": []any{"old=/tmp/old.key", "new=/tmp/new.key"}, + }, + }, asMap) + + attr, err := MarshalAsSlogDict(cfg, "config") + require.NoError(t, err) + assert.Equal(t, slog.Group("config", slog.Group("environment-encryption", + slog.Any("key-files", []any{"old=/tmp/old.key", "new=/tmp/new.key"}), + )), attr) +} + +func Test_MarshalStringSlices(t *testing.T) { + for _, tt := range []struct { + name string + values []string + wantValues []any + wantLog string + }{ + {name: "nil"}, + {name: "empty", values: []string{}, wantValues: []any{}, wantLog: "[]"}, + { + name: "secret elements", + values: []string{"abc", "abcde", "long-secret-value", ""}, + wantValues: []any{"***", "ab***de", "lo***ue", "***"}, + wantLog: "[*** ab***de lo***ue ***]", + }, + } { + t.Run(tt.name, func(t *testing.T) { + cfg := &struct { + Values []string `secret:"true"` + }{Values: slices.Clone(tt.values)} + wantMap := map[string]any{} + var wantAttrs []slog.Attr + if tt.values != nil { + wantMap["values"] = tt.wantValues + wantAttrs = append(wantAttrs, slog.Any("values", tt.wantValues)) + } + + asMap, err := MarshalAsMap(cfg) + require.NoError(t, err) + assert.Equal(t, wantMap, asMap) + + attr, err := MarshalAsSlogDict(cfg, "config") + require.NoError(t, err) + assert.Equal(t, "config", attr.Key) + assert.ElementsMatch(t, wantAttrs, attr.Value.Group()) + if tt.values != nil { + assert.Equal(t, tt.wantLog, attr.Value.Group()[0].Value.String()) + } + assert.Equal(t, tt.values, cfg.Values, "marshaling must not modify the config") + }) + } +} + +func Test_MarshalPrimitiveSlices(t *testing.T) { + for _, tt := range []struct { + name string + config any + wantValues []any + wantLog string + }{ + {"int", &struct{ Values []int }{[]int{-42, 0, 17}}, []any{int64(-42), int64(0), int64(17)}, "[-42 0 17]"}, + {"int8", &struct{ Values []int8 }{[]int8{-128, 127}}, []any{int64(-128), int64(127)}, "[-128 127]"}, + {"int16", &struct{ Values []int16 }{[]int16{-32768, 32767}}, []any{int64(-32768), int64(32767)}, "[-32768 32767]"}, + {"int32", &struct{ Values []int32 }{[]int32{-2147483648, 2147483647}}, []any{int64(-2147483648), int64(2147483647)}, "[-2147483648 2147483647]"}, + {"int64", &struct{ Values []int64 }{[]int64{-1 << 63, 1<<63 - 1}}, []any{int64(-1 << 63), int64(1<<63 - 1)}, "[-9223372036854775808 9223372036854775807]"}, + {"uint", &struct{ Values []uint }{[]uint{0, 42}}, []any{uint64(0), uint64(42)}, "[0 42]"}, + {"uint8", &struct{ Values []uint8 }{[]uint8{0, 255}}, []any{uint64(0), uint64(255)}, "[0 255]"}, + {"uint16", &struct{ Values []uint16 }{[]uint16{0, 65535}}, []any{uint64(0), uint64(65535)}, "[0 65535]"}, + {"uint32", &struct{ Values []uint32 }{[]uint32{0, 4294967295}}, []any{uint64(0), uint64(4294967295)}, "[0 4294967295]"}, + {"uint64", &struct{ Values []uint64 }{[]uint64{0, 1<<64 - 1}}, []any{uint64(0), uint64(1<<64 - 1)}, "[0 18446744073709551615]"}, + {"float32", &struct{ Values []float32 }{[]float32{-1.5, 0, 2.25}}, []any{float64(-1.5), float64(0), float64(2.25)}, "[-1.5 0 2.25]"}, + {"float64", &struct{ Values []float64 }{[]float64{-3.125, 0, 8.5}}, []any{-3.125, float64(0), 8.5}, "[-3.125 0 8.5]"}, + {"bool", &struct{ Values []bool }{[]bool{false, true}}, []any{false, true}, "[false true]"}, + {"duration", &struct{ Values []time.Duration }{[]time.Duration{-time.Second, 0, 1500 * time.Millisecond}}, []any{"-1s", "0s", "1.5s"}, "[-1s 0s 1.5s]"}, + {"nested", &struct{ Values [][]int }{[][]int{{-3, 0}, {7}}}, []any{[]any{int64(-3), int64(0)}, []any{int64(7)}}, "[[-3 0] [7]]"}, + { + "nested secrets", + &struct { + Values [][]string `secret:"true"` + }{[][]string{{"abc", "long-secret-value"}, {"abcde"}}}, + []any{[]any{"***", "lo***ue"}, []any{"ab***de"}}, + "[[*** lo***ue] [ab***de]]", + }, + } { + t.Run(tt.name, func(t *testing.T) { + asMap, err := MarshalAsMap(tt.config) + require.NoError(t, err) + assert.Equal(t, map[string]any{"values": tt.wantValues}, asMap) + + attr, err := MarshalAsSlogDict(tt.config, "config") + require.NoError(t, err) + assert.Equal(t, slog.Group("config", slog.Any("values", tt.wantValues)), attr) + assert.Equal(t, tt.wantLog, attr.Value.Group()[0].Value.String()) + }) + } + + t.Run("unsupported elements", func(t *testing.T) { + cfg := &struct{ Values []map[string]int }{Values: []map[string]int{{"key": 1}}} + _, err := MarshalAsMap(cfg) + require.EqualError(t, err, "slice element 0: unknown field type map") + _, err = MarshalAsSlogDict(cfg, "config") + require.EqualError(t, err, "slice element 0: unknown field type map") + }) +} + +func Test_MarshalAsSlogDictJSON(t *testing.T) { + type nestedConfig struct { + Count int + Limit uint64 + Ratio float64 + Enabled bool + Timeout time.Duration + Token string `secret:"true"` + Tokens []string `secret:"true"` + Matrix [][]int + Empty []bool + Absent []string + } + cfg := &struct { + Name string + Active bool + Nested nestedConfig + }{ + Name: "worker", + Active: true, + Nested: nestedConfig{ + Count: -7, Limit: 42, Ratio: 1.25, + Timeout: 1500 * time.Millisecond, + Token: "secret-token", + Tokens: []string{"abc", "long-secret-value"}, + Matrix: [][]int{{-3, 0}, {7}}, + Empty: []bool{}, + }, + } + attr, err := MarshalAsSlogDict(cfg, "config") + require.NoError(t, err) + + var output strings.Builder + handler := slog.NewJSONHandler(&output, nil) + record := slog.NewRecord(time.Time{}, slog.LevelInfo, "loaded", 0) + record.AddAttrs(attr) + require.NoError(t, handler.Handle(t.Context(), record)) + assert.JSONEq(t, `{ + "level": "INFO", "msg": "loaded", + "config": { + "name": "worker", "active": true, + "nested": { + "count": -7, "limit": 42, "ratio": 1.25, "enabled": false, + "timeout": "1.5s", "token": "se***en", + "tokens": ["***", "lo***ue"], "matrix": [[-3, 0], [7]], "empty": [] + } + } + }`, output.String()) +} + func SetArgsForTest(t *testing.T, args []string) { oldArgs := os.Args @@ -782,6 +1659,72 @@ func SetArgsForTest(t *testing.T, args []string) { os.Args = args } +func TestValidationMessages(t *testing.T) { + for _, tc := range []struct { + name, rule, want string + invalid, valid any + }{ + {"required", "required", "--value is required", "", "present"}, + {"choices", "oneof='red green' blue", "--value must be one of: 'red green' blue", "private-input", "red green"}, + {"case insensitive choices", "oneofci=red blue", "--value must be one of: red blue", "private-input", "RED"}, + {"optional string", "omitempty,min=1", "--value must contain at least 1 character", new(""), (*string)(nil)}, + {"unicode length", "min=2", "--value must contain at least 2 characters", "é", "éø"}, + {"maximum length", "max=1", "--value must contain at most 1 character", "ab", "é"}, + {"exact length", "len=2", "--value must contain exactly 2 characters", "a", "ab"}, + {"slice minimum", "min=1", "--value must contain at least 1 item", []string{}, []string{"a"}}, + {"map maximum", "max=1", "--value must contain at most 1 item", map[string]int{"a": 1, "b": 2}, map[string]int{"a": 1}}, + {"slice exact length", "len=2", "--value must contain exactly 2 items", []int{1}, []int{1, 2}}, + {"numeric minimum", "min=2", "--value must be at least 2", 1, 2}, + {"numeric maximum", "max=2", "--value must be at most 2", 3, 2}, + {"numeric exact", "len=2", "--value must be exactly 2", 3, 2}, + {"inclusive lower bound", "gte=0", "--value must be at least 0", new(-1), new(0)}, + {"inclusive upper bound", "lte=100", "--value must be at most 100", uint(101), uint(100)}, + {"exclusive lower bound", "gt=0", "--value must be greater than 0", 0, 1}, + {"exclusive upper bound", "lt=1.5", "--value must be less than 1.5", 1.5, 1.4}, + {"string lower bound", "gt=1", "--value must contain more than 1 character", "a", "ab"}, + {"slice upper bound", "lt=2", "--value must contain fewer than 2 items", []int{1, 2}, []int{1}}, + {"string inequality", "ne=reserved", `--value must not equal "reserved"`, "reserved", "allowed"}, + {"number equality", "eq=2", "--value must equal 2", 3, 2}, + {"slice equality", "eq=1", "--value must contain exactly 1 item", []int{1, 2}, []int{1}}, + {"slice inequality", "ne=2", "--value must not contain exactly 2 items", []int{1, 2}, []int{1}}, + {"uuid", "uuid", "--value must be a valid UUID", "private-input", "019e4f3c-4646-7312-b8fe-2e7fa83c1546"}, + {"url", "url", "--value must be a valid URL", "private-input", "https://example.com"}, + {"email", "email", "--value must be a valid email address", "private-input", "user@example.com"}, + {"letters", "alpha", "--value must contain only letters", "abc1", "abc"}, + {"alphanumeric", "alphanum", "--value must contain only letters and digits", "abc-1", "abc1"}, + {"contains", "contains=prefix", `--value must contain "prefix"`, "private-input", "prefix-value"}, + {"excludes", "excludes=/", `--value must not contain "/"`, "a/b", "ab"}, + {"prefix", "startswith=prefix", `--value must start with "prefix"`, "private-input", "prefix-value"}, + {"suffix", "endswith=.json", `--value must end with ".json"`, "private-input", "config.json"}, + {"fallback", "hexcolor", "--value does not satisfy the hexcolor constraint", "private-input", "#ffffff"}, + } { + t.Run(tc.name, func(t *testing.T) { + typeOf := reflect.StructOf([]reflect.StructField{{ + Name: "Value", Type: reflect.TypeOf(tc.invalid), + Tag: reflect.StructTag(fmt.Sprintf(`flag:"value" secret:"true" validate:%q help:"input value"`, tc.rule)), + }}) + config := reflect.New(typeOf) + config.Elem().Field(0).Set(reflect.ValueOf(tc.invalid)) + err := Validate(config.Interface()) + require.EqualError(t, err, tc.want) + var validation *ValidationError + require.ErrorAs(t, err, &validation) + require.Len(t, validation.Issues, 1) + assert.Equal(t, []FieldInfo{{Path: "Value", Name: "--value", Help: "input value"}}, validation.Issues[0].Fields) + assert.NotContains(t, err.Error(), "private-input") + config.Elem().Field(0).Set(reflect.ValueOf(tc.valid)) + assert.NoError(t, Validate(config.Interface())) + }) + } + + t.Run("unbound field", func(t *testing.T) { + config := &struct { + Name string `flag:"-" validate:"required"` + }{} + assert.EqualError(t, Validate(config), "Name is required") + }) +} + func Test_validate(t *testing.T) { type config struct { RequiredValue string `validate:"required"` @@ -820,7 +1763,7 @@ func Test_validate(t *testing.T) { NumberBetween0to10: 5, }, }, - wantErr: "Missing required configuration: config.RequiredValue", + wantErr: "--required-value is required", }, { name: "multiple errors", @@ -830,10 +1773,10 @@ func Test_validate(t *testing.T) { }, }, wantErr: strings.TrimSpace(` -Missing required configuration: config.RequiredValue -Configuration error: Alphanumeric - alphanum -Configuration error: Contains - contains -Configuration error: NumberBetween0to10 - gte +--required-value is required +--alphanumeric must contain only letters and digits +--contains must contain "MustContain" +--number-between-0-to-10 must be at least 0 `), }, } diff --git a/tags.go b/tags.go index f6f4a15..96e45f6 100644 --- a/tags.go +++ b/tags.go @@ -58,21 +58,25 @@ func init() { //nolint:gochecknoinits } type configFieldTags struct { - flag string - arg string - aliases []string - isGlobal bool - isSecret bool + fieldName string + flag string + arg string + aliases []string + isGlobal bool + isSecret bool hasExplicitFlag bool - json string toml string - yaml string env string defaultValue string + hasDefault bool + explicitEnv bool + sliceMode string + nargs string + file bool help string } @@ -80,6 +84,8 @@ func parseTags(tag *reflect.StructTag) *configFieldTags { isGlobal, _ := strconv.ParseBool(tag.Get("global")) isSecret, _ := strconv.ParseBool(tag.Get("secret")) flag, hasExplicitFlag := tag.Lookup("flag") + defaultValue, hasDefault := tag.Lookup("default") + file, _ := strconv.ParseBool(tag.Get("file")) parsed := &configFieldTags{ flag: flag, @@ -89,12 +95,15 @@ func parseTags(tag *reflect.StructTag) *configFieldTags { hasExplicitFlag: hasExplicitFlag, - json: tag.Get("json"), toml: tag.Get("toml"), - yaml: tag.Get("yaml"), env: tag.Get("env"), - defaultValue: tag.Get("default"), + explicitEnv: tag.Get("env") != "" && tag.Get("env") != "-", + defaultValue: defaultValue, + hasDefault: hasDefault, + sliceMode: tag.Get("slice"), + nargs: tag.Get("nargs"), + file: file, help: tag.Get("help"), } @@ -113,6 +122,7 @@ func parseTags(tag *reflect.StructTag) *configFieldTags { func parseTagsWithFieldNameDefault(tag *reflect.StructTag, fieldName string) *configFieldTags { tags := parseTags(tag) + tags.fieldName = fieldName isExported := len(fieldName) > 0 && fieldName[0] >= 'A' && fieldName[0] <= 'Z' kebab := strcase.ToKebab(fieldName) @@ -121,18 +131,10 @@ func parseTagsWithFieldNameDefault(tag *reflect.StructTag, fieldName string) *co tags.flag = kebab } - if isExported && tags.json == "" { - tags.json = strcase.ToLowerCamel(fieldName) - } - if isExported && tags.toml == "" { tags.toml = kebab } - if isExported && tags.yaml == "" { - tags.yaml = kebab - } - if isExported && tags.env == "" { tags.env = strcase.ToScreamingSnake(fieldName) } diff --git a/toml.go b/toml.go index cb8c38e..f431736 100644 --- a/toml.go +++ b/toml.go @@ -9,19 +9,8 @@ import ( "github.com/urfave/cli/v3" ) -// workaround until https://github.com/urfave/cli/issues/2037 and https://github.com/urfave/cli-altsrc/issues/14 -// are fixed - -type mapSource struct { - name string - m map[any]any -} - func NewMapSource(name string, m map[any]any) cli.MapSource { - return &mapSource{ - name: name, - m: m, - } + return cli.NewMapSource(name, m) } func NewTomlFileSource(name string, file string) (cli.MapSource, error) { @@ -38,49 +27,6 @@ func NewTomlFileSource(name string, file string) (cli.MapSource, error) { return NewMapSource(name, container), nil } -func (ms *mapSource) String() string { return fmt.Sprintf("map source %[1]q", ms.name) } -func (ms *mapSource) GoString() string { - return fmt.Sprintf("&mapSource{flag:%[1]q}", ms.name) -} - -func (ms *mapSource) Lookup(name string) (any, bool) { - node := ms.m - sections := strings.Split(name, ".") - - if len(sections) == 0 { - return nil, false - } - - // recurse into the map, splitting the key on "." - if len(sections) > 1 { - for _, section := range sections[:len(sections)-1] { - child, ok := node[section] - if !ok { - return nil, false - } - - switch child := child.(type) { - case map[string]any: - node = make(map[any]any, len(child)) - for k, v := range child { - node[k] = v - } - case map[any]any: - node = child - default: - return nil, false - } - } - } - - // now lookup the last section - if val, ok := node[sections[len(sections)-1]]; ok { - return val, true - } - - return nil, false -} - type mapsValueSource struct { key string maps []cli.MapSource diff --git a/validate.go b/validate.go index 9d9d84b..5d449d9 100644 --- a/validate.go +++ b/validate.go @@ -1,35 +1,225 @@ package structconf import ( - "bytes" "errors" "fmt" + "reflect" + "strconv" + "strings" "github.com/go-playground/validator/v10" "github.com/urfave/cli/v3" ) +// ValidationIssue describes a failed constraint, possibly involving several +// fields. Rule and Param are optional; input values are deliberately excluded. +type ValidationIssue struct { + Message string + Fields []FieldInfo + Rule string + Param string +} + +// ValidationError can be returned by both structconf and application validators. +// Applications may construct it directly for inputs not bound by structconf. +type ValidationError struct { + Issues []ValidationIssue +} + +// NewValidationError resolves Go field paths to their CLI names and help text. +// Unknown paths retain their Go name, without inventing a CLI binding. For +// manually defined inputs, construct ValidationError with explicit FieldInfo. +func NewValidationError(message string, config any, paths ...string) *ValidationError { + metadata := fieldInfoByPath(config) + fields := make([]FieldInfo, 0, len(paths)) + for _, path := range paths { + fields = append(fields, resolveFieldInfo(reflect.TypeOf(config), path, metadata)) + } + return &ValidationError{Issues: []ValidationIssue{{Message: message, Fields: fields}}} +} + +func fieldInfoByPath(config any) map[string]FieldInfo { + fields, _ := Describe(config) + metadata := make(map[string]FieldInfo, len(fields)) + for _, field := range fields { + metadata[field.Path] = field + } + return metadata +} + +func resolveFieldInfo(t reflect.Type, path string, metadata map[string]FieldInfo) FieldInfo { + info, ok := metadata[resolveFieldPath(t, path)] + if !ok { + return FieldInfo{Path: path} + } + if strings.Contains(path, "[") { + info.Path = path + } + return info +} + +func (e *ValidationError) Error() string { + messages := make([]string, len(e.Issues)) + for i, issue := range e.Issues { + messages[i] = issue.Message + } + return strings.Join(messages, "\n") +} + +// Resolve promoted fields through reflect's field indexes, rather than guessing +// by suffix. A primitive slice's indexed validation failure uses its flag help. +func resolveFieldPath(t reflect.Type, path string) string { + var names []string + for part := range strings.SplitSeq(path, ".") { + if t == nil { + return path + } + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return path + } + name, suffix, indexed := strings.Cut(part, "[") + field, ok := t.FieldByName(name) + if !ok { + return path + } + for _, index := range field.Index { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + f := t.Field(index) + names = append(names, f.Name) + t = f.Type + } + if indexed { + if t.Kind() != reflect.Slice && t.Kind() != reflect.Array { + return path + } + indexText, closed := strings.CutSuffix(suffix, "]") + index, err := strconv.Atoi(indexText) + if !closed || err != nil || strings.Trim(indexText, "0123456789") != "" || index < 0 || (t.Kind() == reflect.Array && index >= t.Len()) { + return path + } + t = t.Elem() + } + } + return strings.Join(names, ".") +} + func validate(_ *cli.Command, configPointer any) error { + return Validate(configPointer) +} + +// Validate applies validate tags and returns a structured ValidationError. +// Custom validators may call it before performing application-specific checks. +func Validate(configPointer any) error { configValidator := validator.New(validator.WithRequiredStructEnabled()) err := configValidator.Struct(configPointer) if err != nil { - var validationErrors validator.ValidationErrors - if errors.As(err, &validationErrors) { - errorMessage := &bytes.Buffer{} + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + result := &ValidationError{} + metadata := fieldInfoByPath(configPointer) + t := reflect.TypeOf(configPointer) + if t.Kind() == reflect.Pointer { + t = t.Elem() + } for _, fieldError := range validationErrors { - validationTag := fieldError.Tag() - if validationTag == "required" { - fmt.Fprintf(errorMessage, "Missing required configuration: %s\n", fieldError.Namespace()) - } else { - fmt.Fprintf(errorMessage, "Configuration error: %s - %s\n", fieldError.StructField(), fieldError.ActualTag()) + path := strings.TrimPrefix(fieldError.StructNamespace(), t.Name()+".") + issue := ValidationIssue{ + Fields: []FieldInfo{resolveFieldInfo(t, path, metadata)}, + Rule: fieldError.ActualTag(), + Param: fieldError.Param(), } + issue.Message = validationMessage(issue.Fields[0], fieldError) + result.Issues = append(result.Issues, issue) } - return errors.New(errorMessage.String()) + return result } + return err } return nil } + +func validationMessage(field FieldInfo, failure validator.FieldError) string { + name := field.Name + if name == "" { + name = field.Path + } + param := failure.Param() + switch failure.ActualTag() { + case "required": + return name + " is required" + case "oneof", "oneofci": + return name + " must be one of: " + param + case "min", "max", "len", "gte", "lte", "gt", "lt": + comparison := map[string]string{ + "min": "at least", "gte": "at least", "max": "at most", "lte": "at most", + "len": "exactly", "gt": "more than", "lt": "fewer than", + }[failure.ActualTag()] + switch failure.Kind() { //nolint:exhaustive // Other kinds retain the generic constraint message. + case reflect.String, reflect.Slice, reflect.Array, reflect.Map: + unit := "items" + if failure.Kind() == reflect.String { + unit = "characters" + } + if param == "1" { + unit = strings.TrimSuffix(unit, "s") + } + return fmt.Sprintf("%s must contain %s %s %s", name, comparison, param, unit) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, + reflect.Float32, reflect.Float64: + if failure.ActualTag() == "gt" { + comparison = "greater than" + } else if failure.ActualTag() == "lt" { + comparison = "less than" + } + return fmt.Sprintf("%s must be %s %s", name, comparison, param) + } + case "eq", "ne": + comparison := "must equal" + if failure.ActualTag() == "ne" { + comparison = "must not equal" + } + switch failure.Kind() { //nolint:exhaustive // Other kinds use the literal constraint parameter. + case reflect.String: + param = strconv.Quote(param) + case reflect.Slice, reflect.Array, reflect.Map: + comparison = "must contain exactly" + if failure.ActualTag() == "ne" { + comparison = "must not contain exactly" + } + if param == "1" { + param += " item" + } else { + param += " items" + } + } + return fmt.Sprintf("%s %s %s", name, comparison, param) + case "uuid": + return name + " must be a valid UUID" + case "url": + return name + " must be a valid URL" + case "email": + return name + " must be a valid email address" + case "alpha": + return name + " must contain only letters" + case "alphanum": + return name + " must contain only letters and digits" + case "contains": + return fmt.Sprintf("%s must contain %q", name, param) + case "excludes": + return fmt.Sprintf("%s must not contain %q", name, param) + case "startswith": + return fmt.Sprintf("%s must start with %q", name, param) + case "endswith": + return fmt.Sprintf("%s must end with %q", name, param) + } + return fmt.Sprintf("%s does not satisfy the %s constraint", name, failure.Tag()) +} From 3c4d59f52e3ed6f361323da38c4ddd5dc1db470a Mon Sep 17 00:00:00 2001 From: Lukas Bindreiter Date: Mon, 9 Nov 2026 17:30:24 +0100 Subject: [PATCH 2/2] Prepare changelog --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3d6b1..b187d68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-11-09 + ### Added - Added `slice:"comma-separated"` (the default) and `slice:"repeated-flag"` for per-field slice parsing, plus loading slices of all supported scalar types. Repeated flags preserve literal commas; native TOML arrays preserve element boundaries in both modes. @@ -75,7 +77,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.5.0...HEAD +[Unreleased]: https://github.com/tilebox/structconf/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/tilebox/structconf/compare/v0.5.0...v0.6.0 [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