From 90212b9cb1e36a653b7a8f50c62491e63f1c3c14 Mon Sep 17 00:00:00 2001 From: Lukas Bindreiter Date: Tue, 12 May 2026 14:16:53 +0200 Subject: [PATCH 1/2] Allow custom validators --- .golangci.yaml | 19 ++-- README.md | 37 +++++--- config.go | 4 +- examples/01_simple_cli/main.go | 2 +- examples/02_default_values/main.go | 2 +- examples/03_nested/main.go | 2 +- examples/04_toml/main.go | 2 +- examples/05_override/main.go | 2 +- examples/06_global/main.go | 2 +- examples/07_marshal/main.go | 2 +- examples/08_validation/main.go | 2 +- examples/09_subcommands/main.go | 2 +- go.mod | 16 ++-- go.sum | 28 +++--- marshal.go | 4 +- structconf.go | 138 +++++++++++++++++------------ structconf_test.go | 85 ++++++++++++++---- toml.go | 2 +- 18 files changed, 222 insertions(+), 129 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 11e3545..b43367f 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -2,7 +2,7 @@ --- version: "2" linters: - enable: # list taken from https://golangci-lint.run/usage/linters/ - last updated 2026-01-02 for v2.7.2 + enable: # list taken from https://golangci-lint.run/usage/linters/ - last updated 2026-05-11 for v2.12.2 # enabled by default, but list them here to be explicit - errcheck - govet @@ -16,6 +16,7 @@ linters: - bidichk # checks for dangerous unicode character sequences - bodyclose # checks whether HTTP response body is closed successfully - canonicalheader # checks for canonical names in HTTP headers + - clickhouselint # detects common mistakes with the ClickHouse native Go driver API - containedctx # detects struct contained context.Context field #- contextcheck # checks for inherited context.Context - copyloopvar # detects places where loop variables are copied @@ -35,7 +36,7 @@ linters: #- exhaustruct # checks if all structure fields are initialized - exptostd # Detects functions from golang.org/x/exp/ that can be replaced by std functions. - fatcontext # finds nested context.WithValue calls in loops - #- forbidigo # forbids identifiers + - forbidigo # forbids identifiers #- forcetypeassert # finds forced type assertions - funcorder # checks that functions are in the right order #- funlen # Tool for detection of long functions @@ -45,7 +46,7 @@ linters: - gochecknoinits # checks that no init functions are present in Go code - gochecksumtype # checks exhaustiveness on Go "sum types" #- gocognit # Computes and checks the cognitive complexity of functions - - goconst # finds repeated strings that could be replaced by a constant + #- goconst # finds repeated strings that could be replaced by a constant - gocritic # provides diagnostics that check for bugs, performance and style issues #- gocyclo # Computes and checks the cyclomatic complexity of functions - godoclint # Checks golang docs best practices (godoc) @@ -53,7 +54,8 @@ linters: #- godox # Tool for detection of FIXME, TODO and other comment keywords #- goheader # Checks is file header matches to pattern - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod - - gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations + #- gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations + - gomodguard_v2 # Allow and blocklist linter for direct Go module dependencies - goprintffuncname # checks that printf-like functions are named with f at the end - gosec # inspects source code for security problems - gosmopolitan # Report certain i18n/l10n anti-patterns in your Go codebase. @@ -115,7 +117,7 @@ linters: - whitespace # detects leading and trailing whitespace #- wrapcheck # Checks that errors returned from external packages are wrapped #- wsl # (deprecated) - # - wsl_v5 # whitespace linter - add or remove empty lines + #- wsl_v5 # whitespace linter - add or remove empty lines #- zerologlint # checks wrong usage of zerolog settings: gosec: @@ -166,9 +168,14 @@ linters: - containedctx - dogsled path: _test.go + # Examples are allowed to write directly to stdout. + - linters: + - forbidigo + path: ^examples/ + text: use of `fmt\.Print(|f|ln)` forbidden formatters: enable: - gci - gofmt - gofumpt - - goimports \ No newline at end of file + - goimports diff --git a/README.md b/README.md index 1e4076d..a062530 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ type ProgramConfig struct { func main() { cfg := &ProgramConfig{} - structconf.MustLoadAndValidate(cfg, "greetings") + structconf.MustLoad(cfg, "greetings") if cfg.Greet { fmt.Printf("Hello %s!\n", cfg.Name) } @@ -92,7 +92,7 @@ type ProgramConfig struct { func main() { cfg := &ProgramConfig{} - structconf.MustLoadAndValidate(cfg, + structconf.MustLoad(cfg, "greetings", structconf.WithVersion("1.0.0"), structconf.WithDescription("Print a greeting"), @@ -150,7 +150,7 @@ type AppConfig struct { func main() { cfg := &AppConfig{} - structconf.MustLoadAndValidate(cfg, "app") + structconf.MustLoad(cfg, "app") fmt.Printf("%v", cfg) } @@ -176,7 +176,7 @@ type AppConfig struct { func main() { cfg := &AppConfig{} - structconf.MustLoadAndValidate(cfg, + structconf.MustLoad(cfg, "app", // adds a --load-config flag to load config from TOML files structconf.WithLoadConfigFlag("load-config"), @@ -251,15 +251,15 @@ func main() { } ``` -`BindCommand` and `NewCommand` currently support flags, env vars and default values. `WithLoadConfigFlag` is currently only supported by `LoadAndValidate` / `MustLoadAndValidate`. +`BindCommand` and `NewCommand` currently support flags, env vars and default values. `WithLoadConfigFlag` is currently only supported by `Load` / `MustLoad`. ### Parse custom arg slices -If you need to parse a specific arg slice (for tests or embedding), use `LoadAndValidateArgs`: +If you need to parse a specific arg slice (for tests or embedding), use `LoadArgs`: ```go cfg := &AppConfig{} -err := structconf.LoadAndValidateArgs(cfg, "app", []string{"app", "--log-level", "DEBUG"}) +err := structconf.LoadArgs(cfg, "app", []string{"app", "--log-level", "DEBUG"}) if err != nil { panic(err) } @@ -270,7 +270,7 @@ if err != nil { Enable completion in code: ```go -structconf.MustLoadAndValidate(cfg, "app", structconf.WithShellCompletions()) +structconf.MustLoad(cfg, "app", structconf.WithShellCompletions()) ``` Then install it in your shell: @@ -327,7 +327,7 @@ type NestedConfig struct { func main() { cfg := &AppConfig{} - structconf.MustLoadAndValidate(cfg, "app") + structconf.MustLoad(cfg, "app") fmt.Println(cfg.Deeply.Nested.Name) } ``` @@ -366,7 +366,7 @@ type AppConfig struct { func main() { cfg := &AppConfig{} - structconf.MustLoadAndValidate(cfg, "app") + structconf.MustLoad(cfg, "app") asMap, err := structconf.MarshalAsMap(cfg) if err != nil { @@ -410,7 +410,7 @@ type AppConfig struct { func main() { cfg := &AppConfig{} - structconf.MustLoadAndValidate(cfg, "app") + structconf.MustLoad(cfg, "app") } ``` @@ -419,3 +419,18 @@ $ ./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()) +} +``` + +For configs bound to subcommands with `BindCommand` / `NewCommand`, use `WithDisableCommandValidation` instead. diff --git a/config.go b/config.go index 73b0afb..deefc07 100644 --- a/config.go +++ b/config.go @@ -433,7 +433,7 @@ func (r *structReflector) recurseStruct(anyStruct any, parents []*configFieldTag structType := reflect.TypeOf(anyStruct) structValues := reflect.ValueOf(anyStruct) - if structType.Kind() == reflect.Ptr { + if structType.Kind() == reflect.Pointer { structType = structType.Elem() structValues = structValues.Elem() } @@ -456,7 +456,7 @@ func (r *structReflector) recurseStruct(anyStruct any, parents []*configFieldTag continue } - if fieldType.Type.Kind() == reflect.Ptr && fieldType.Type.Elem().Kind() == reflect.Struct { + if fieldType.Type.Kind() == reflect.Pointer && fieldType.Type.Elem().Kind() == reflect.Struct { if fieldValue.IsNil() { fieldValue.Set(reflect.New(fieldType.Type.Elem())) } diff --git a/examples/01_simple_cli/main.go b/examples/01_simple_cli/main.go index 6dc5ef0..5999588 100644 --- a/examples/01_simple_cli/main.go +++ b/examples/01_simple_cli/main.go @@ -14,7 +14,7 @@ type ProgramConfig struct { // usage: ./simple_cli --greet --name "World" func main() { cfg := &ProgramConfig{} - structconf.MustLoadAndValidate(cfg, + structconf.MustLoad(cfg, "simple_cli", structconf.WithVersion("1.0.0"), ) diff --git a/examples/02_default_values/main.go b/examples/02_default_values/main.go index 3144907..7e3f97a 100644 --- a/examples/02_default_values/main.go +++ b/examples/02_default_values/main.go @@ -16,7 +16,7 @@ type ProgramConfig struct { // ./simple_cli -h func main() { cfg := &ProgramConfig{} - structconf.MustLoadAndValidate(cfg, + structconf.MustLoad(cfg, "greetings", structconf.WithVersion("1.0.0"), structconf.WithDescription("Print a greeting"), diff --git a/examples/03_nested/main.go b/examples/03_nested/main.go index ba6bd92..2e22c7e 100644 --- a/examples/03_nested/main.go +++ b/examples/03_nested/main.go @@ -26,7 +26,7 @@ type AppConfig struct { // usage: ./app --database-user=myuser --database-password=mypassword --server-host=localhost --server-port=8080 --log-level=DEBUG func main() { cfg := &AppConfig{} - structconf.MustLoadAndValidate(cfg, + structconf.MustLoad(cfg, "app", structconf.WithVersion("1.0.0"), ) diff --git a/examples/04_toml/main.go b/examples/04_toml/main.go index f288839..78025d3 100644 --- a/examples/04_toml/main.go +++ b/examples/04_toml/main.go @@ -18,7 +18,7 @@ type AppConfig struct { // usage: ./app --load-config database.toml --log-level=debug func main() { cfg := &AppConfig{} - structconf.MustLoadAndValidate(cfg, + structconf.MustLoad(cfg, "app", structconf.WithVersion("1.0.0"), // adds a --load-config flag to load config from TOML files diff --git a/examples/05_override/main.go b/examples/05_override/main.go index 00712fb..3525833 100644 --- a/examples/05_override/main.go +++ b/examples/05_override/main.go @@ -18,7 +18,7 @@ type AppConfig struct { // usage: ./app --load-config database.toml --log-level=debug func main() { cfg := &AppConfig{} - structconf.MustLoadAndValidate(cfg, + structconf.MustLoad(cfg, "app", structconf.WithVersion("1.0.0"), ) diff --git a/examples/06_global/main.go b/examples/06_global/main.go index 9fa1e10..09c9c04 100644 --- a/examples/06_global/main.go +++ b/examples/06_global/main.go @@ -21,7 +21,7 @@ type NestedConfig struct { // usage: ./app --load-config database.toml --log-level=debug func main() { cfg := &AppConfig{} - structconf.MustLoadAndValidate(cfg, + structconf.MustLoad(cfg, "app", structconf.WithVersion("1.0.0"), ) diff --git a/examples/07_marshal/main.go b/examples/07_marshal/main.go index 741d5f4..9f8c038 100644 --- a/examples/07_marshal/main.go +++ b/examples/07_marshal/main.go @@ -20,7 +20,7 @@ type AppConfig struct { // usage: ./app --database-user=my-user --database-password=very-secret-password --log-level=INFO func main() { cfg := &AppConfig{} - structconf.MustLoadAndValidate(cfg, + structconf.MustLoad(cfg, "app", structconf.WithVersion("1.0.0"), ) diff --git a/examples/08_validation/main.go b/examples/08_validation/main.go index 2641e64..41aebc8 100644 --- a/examples/08_validation/main.go +++ b/examples/08_validation/main.go @@ -21,5 +21,5 @@ type AppConfig struct { // usage: ./app --log-level=DEBUG --port=8080 --host=localhost --path=/tmp/ func main() { cfg := &AppConfig{} - structconf.MustLoadAndValidate(cfg, "app", structconf.WithVersion("1.0.0")) + structconf.MustLoad(cfg, "app", structconf.WithVersion("1.0.0")) } diff --git a/examples/09_subcommands/main.go b/examples/09_subcommands/main.go index 22d46cc..97fd6c9 100644 --- a/examples/09_subcommands/main.go +++ b/examples/09_subcommands/main.go @@ -39,7 +39,7 @@ func main() { fmt.Println(greetCfg.Name) return nil - }, structconf.WithDescription("Print a greeting")) + }) if err != nil { panic(err) } diff --git a/go.mod b/go.mod index 9a8b631..f26f068 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,25 @@ module github.com/tilebox/structconf -go 1.24.0 +go 1.25.0 require ( github.com/BurntSushi/toml v1.6.0 - github.com/go-playground/validator/v10 v10.30.1 + github.com/go-playground/validator/v10 v10.30.2 github.com/iancoleman/strcase v0.3.0 - github.com/samber/lo v1.52.0 + github.com/samber/lo v1.53.0 github.com/stretchr/testify v1.11.1 - github.com/urfave/cli/v3 v3.6.1 + github.com/urfave/cli/v3 v3.8.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.12 // 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.46.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.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 ) diff --git a/go.sum b/go.sum index a34baba..4efce2a 100644 --- a/go.sum +++ b/go.sum @@ -2,34 +2,34 @@ 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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= -github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +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/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.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= -github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +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/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/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= -github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +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.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= -github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +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= diff --git a/marshal.go b/marshal.go index 95e9e0b..2a89ecf 100644 --- a/marshal.go +++ b/marshal.go @@ -32,7 +32,7 @@ func marshalStruct(anyStruct any, into map[string]any, nameFromTags func(t *conf structType := reflect.TypeOf(anyStruct) structValues := reflect.ValueOf(anyStruct) - if structType.Kind() == reflect.Ptr { + if structType.Kind() == reflect.Pointer { structType = structType.Elem() structValues = structValues.Elem() } @@ -63,7 +63,7 @@ func marshalStruct(anyStruct any, into map[string]any, nameFromTags func(t *conf continue } - if fieldType.Type.Kind() == reflect.Ptr && fieldType.Type.Elem().Kind() == reflect.Struct { + if fieldType.Type.Kind() == reflect.Pointer && fieldType.Type.Elem().Kind() == reflect.Struct { if fieldValue.IsNil() { fieldValue.Set(reflect.New(fieldType.Type.Elem())) } diff --git a/structconf.go b/structconf.go index 2c48b2f..bb24d4f 100644 --- a/structconf.go +++ b/structconf.go @@ -11,36 +11,46 @@ import ( "github.com/urfave/cli/v3" ) -type options struct { +type ConfigValidator func(configPointer any) error + +type cliOptions struct { version string description string longDescription string - enableShellCompletion bool loadConfigFlagName string + enableShellCompletion bool + commandOptions commandOptions } -type Option func(opts *options) +type commandOptions struct { + validator ConfigValidator +} + +type ( + Option func(opts *cliOptions) + CommandOption func(opts *commandOptions) +) func WithVersion(version string) Option { - return func(opts *options) { + return func(opts *cliOptions) { opts.version = version } } func WithDescription(description string) Option { - return func(opts *options) { + return func(opts *cliOptions) { opts.description = description } } func WithLongDescription(usage string) Option { - return func(opts *options) { + return func(opts *cliOptions) { opts.longDescription = usage } } func WithShellCompletions() Option { - return func(opts *options) { + return func(opts *cliOptions) { opts.enableShellCompletion = true } } @@ -50,21 +60,45 @@ func WithDefaultLoadConfigFlag() Option { } func WithLoadConfigFlag(flagName string) Option { - return func(opts *options) { + return func(opts *cliOptions) { opts.loadConfigFlagName = flagName } } -// MustLoadAndValidate is like LoadAndValidate, but if it fails, it prints the error to stderr and exits +func WithValidator(validator ConfigValidator) Option { + return func(opts *cliOptions) { + opts.commandOptions.validator = validator + } +} + +func WithCommandValidator(validator ConfigValidator) CommandOption { + return func(opts *commandOptions) { + opts.validator = validator + } +} + +func WithDisableValidation() Option { + return func(opts *cliOptions) { + opts.commandOptions.validator = func(configPointer any) error { return nil } + } +} + +func WithDisableCommandValidation() CommandOption { + return func(opts *commandOptions) { + opts.validator = func(configPointer any) error { return nil } + } +} + +// MustLoad is like Load, but if it fails, it prints the error to stderr and exits // with a non-zero exit code. -func MustLoadAndValidate(configPointer any, programName string, opts ...Option) { - MustLoadAndValidateArgs(configPointer, programName, os.Args, opts...) +func MustLoad(configPointer any, programName string, opts ...Option) { + MustLoadArgs(configPointer, programName, os.Args, opts...) } -// MustLoadAndValidateArgs is like LoadAndValidateArgs, but if it fails, it prints the error to stderr and exits +// MustLoadArgs is like LoadArgs, but if it fails, it prints the error to stderr and exits // with a non-zero exit code. -func MustLoadAndValidateArgs(configPointer any, programName string, args []string, opts ...Option) { - err := LoadAndValidateArgs(configPointer, programName, args, opts...) +func MustLoadArgs(configPointer any, programName string, args []string, opts ...Option) { + err := LoadArgs(configPointer, programName, args, opts...) if err != nil { helpRequested := &helpRequestedError{} if errors.As(err, &helpRequested) { @@ -80,7 +114,7 @@ func MustLoadAndValidateArgs(configPointer any, programName string, args []strin } } -// LoadAndValidate loads the given config struct and validates it. +// Load loads the given config struct. // // It loads the config from the following sources in the given order: // 1. command line flags @@ -88,29 +122,29 @@ func MustLoadAndValidateArgs(configPointer any, programName string, args []strin // 3. environment variables // 4. default values defined in the field tags // -// It then validates the loaded config, using the validate tag in config fields - if it fails, it returns an error. +// It then runs the configured validator, which uses the validate tag in config fields by default. // The returned error is suitable to be printed to the user. -func LoadAndValidate(configPointer any, programName string, opts ...Option) error { - return LoadAndValidateArgs(configPointer, programName, os.Args, opts...) +func Load(configPointer any, programName string, opts ...Option) error { + return LoadArgs(configPointer, programName, os.Args, opts...) } -// LoadAndValidateArgs is like LoadAndValidate, but allows explicitly providing the CLI args. -func LoadAndValidateArgs(configPointer any, programName string, args []string, opts ...Option) error { - err := loadConfigWithArgs(configPointer, programName, args, opts...) +// LoadArgs is like Load, but allows explicitly providing the CLI args. +func LoadArgs(configPointer any, programName string, args []string, opts ...Option) error { + cfg, err := loadConfigWithArgs(configPointer, programName, args, opts...) if err != nil { return err } - return validate(configPointer) + return cfg.commandOptions.validator(configPointer) } // NewCommand creates a urfave/cli command and binds the given config struct to it. // // When the command is executed, the config is loaded from flags, env vars and default values, -// then validated before the optional action is executed. +// then the configured validator is run before the optional action is executed. // // The WithLoadConfigFlag option is not currently supported for BindCommand/NewCommand. -func NewCommand(configPointer any, commandName string, action cli.ActionFunc, opts ...Option) (*cli.Command, error) { +func NewCommand(configPointer any, commandName string, action cli.ActionFunc, opts ...CommandOption) (*cli.Command, error) { cmd := &cli.Command{ Name: commandName, Action: action, @@ -127,19 +161,17 @@ func NewCommand(configPointer any, commandName string, action cli.ActionFunc, op // BindCommand binds the given config struct to an existing urfave/cli command. // // It appends reflected flags to the command and wraps the command's Action so that config -// loading and validation are run before the existing Action. +// loading and the configured validator are run before the existing Action. // // The WithLoadConfigFlag option is not currently supported for BindCommand/NewCommand. -func BindCommand(command *cli.Command, configPointer any, opts ...Option) error { - cfg := &options{} +func BindCommand(command *cli.Command, configPointer any, opts ...CommandOption) error { + cfg := &commandOptions{ + validator: validate, // default validator + } for _, opt := range opts { opt(cfg) } - if cfg.loadConfigFlagName != "" { - return errors.New("WithLoadConfigFlag is not supported for BindCommand/NewCommand; use LoadAndValidate for top-level commands") - } - config, err := NewStructConfigurator(configPointer, nil) if err != nil { return err @@ -153,23 +185,11 @@ func BindCommand(command *cli.Command, configPointer any, opts ...Option) error } command.Flags = flags - if cfg.enableShellCompletion { - command.EnableShellCompletion = true - } - if cfg.version != "" { - command.Version = cfg.version - } - if cfg.longDescription != "" { - command.Description = cfg.longDescription - } - if cfg.description != "" { - command.Usage = cfg.description - } wrappedAction := command.Action command.Action = func(ctx context.Context, cmd *cli.Command) error { config.Apply(cmd) - if err := validate(configPointer); err != nil { + if err := cfg.validator(configPointer); err != nil { return err } @@ -191,8 +211,12 @@ func (e *helpRequestedError) Error() string { return e.helpText } -func loadConfigWithArgs(configPointer any, programName string, args []string, opts ...Option) error { - cfg := &options{} +func loadConfigWithArgs(configPointer any, programName string, args []string, opts ...Option) (*cliOptions, error) { + cfg := &cliOptions{ + commandOptions: commandOptions{ + validator: validate, // default validator + }, + } for _, opt := range opts { opt(cfg) } @@ -208,13 +232,13 @@ func loadConfigWithArgs(configPointer any, programName string, args []string, op config, err := NewStructConfigurator(configPointer, nil) if err != nil { - return err + return cfg, err } flags := config.Flags() flags = append(flags, loadConfigFlag) if duplicate := firstDuplicateFlagName(flags); duplicate != "" { - return fmt.Errorf("got duplicate flag name: %s", duplicate) + return cfg, fmt.Errorf("got duplicate flag name: %s", duplicate) } stdout := &bytes.Buffer{} @@ -245,13 +269,13 @@ func loadConfigWithArgs(configPointer any, programName string, args []string, op err = cmd.Run(context.Background(), args) if err != nil { if stdout.Len() > 0 { - return errors.New(err.Error() + "\n\n" + stdout.String()) + return cfg, errors.New(err.Error() + "\n\n" + stdout.String()) } - return err + return cfg, err } if stdout.Len() > 0 { // help was requested -> return an error so that we can exit - return &helpRequestedError{ + return cfg, &helpRequestedError{ helpText: stdout.String(), } } @@ -259,7 +283,7 @@ func loadConfigWithArgs(configPointer any, programName string, args []string, op config, err := NewStructConfigurator(configPointer, tomlSources) if err != nil { - return err + return cfg, err } flags := config.Flags() @@ -268,7 +292,7 @@ func loadConfigWithArgs(configPointer any, programName string, args []string, op } if duplicate := firstDuplicateFlagName(flags); duplicate != "" { - return fmt.Errorf("duplicate flag: --%s", duplicate) + return cfg, fmt.Errorf("duplicate flag: --%s", duplicate) } stdout := &bytes.Buffer{} @@ -293,18 +317,18 @@ func loadConfigWithArgs(configPointer any, programName string, args []string, op 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 cfg, errors.New(strings.TrimSpace(err.Error() + "\n\n" + stdout.String())) } - return err + return cfg, err } if stdout.Len() > 0 { // help was requested -> return an error so that we can exit - return &helpRequestedError{ + return cfg, &helpRequestedError{ helpText: strings.TrimSpace(stdout.String()), } } - return nil + return cfg, nil } func firstDuplicateFlagName(flags []cli.Flag) string { diff --git a/structconf_test.go b/structconf_test.go index 80a19be..a296e67 100644 --- a/structconf_test.go +++ b/structconf_test.go @@ -2,6 +2,7 @@ package structconf import ( "context" + "errors" "os" "path" "slices" @@ -59,7 +60,7 @@ func Test_loadConfigFullyTagged(t *testing.T) { SetArgsForTest(t, tt.args.cliArgs) // set cli args, and clean up after the test - err := loadConfigWithArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) + _, err := loadConfigWithArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) require.NoError(t, err) assert.Equal(t, tt.wantValue, config.Value) @@ -115,7 +116,7 @@ func Test_loadConfigDefaultTags(t *testing.T) { SetArgsForTest(t, tt.args.cliArgs) // set cli args, and clean up after the test - err := loadConfigWithArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) + _, err := loadConfigWithArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) require.NoError(t, err) assert.Equal(t, tt.wantValue, config.Value) @@ -222,7 +223,7 @@ duration = "1m5s" t.Setenv(key, value) // set env vars, and clean up after the test } - err := loadConfigWithArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) + _, err := loadConfigWithArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) require.NoError(t, err) assert.Equal(t, tt.wantValue, config.Value) @@ -264,7 +265,7 @@ second = "second_nested_config" SetArgsForTest(t, []string{"my-program", "--load-config", firstConfigPath + "," + secondConfigPath}) cfg := &config{} - err := loadConfigWithArgs(cfg, "my-program", os.Args, WithDefaultLoadConfigFlag()) + _, err := loadConfigWithArgs(cfg, "my-program", os.Args, WithDefaultLoadConfigFlag()) require.NoError(t, err) assert.Equal(t, "first_config", cfg.Value) @@ -301,7 +302,7 @@ func Test_loadConfigExtraFlags(t *testing.T) { t.Run(tt.name, func(t *testing.T) { SetArgsForTest(t, []string{"my-program", "--some-string", "hello", "--some-int", "42", "--unknown-flag", "value"}) - err := loadConfigWithArgs(tt.cfg, "my-program", os.Args, tt.loadOpts...) + _, err := loadConfigWithArgs(tt.cfg, "my-program", os.Args, tt.loadOpts...) require.Error(t, err) assert.Contains(t, err.Error(), "flag provided but not defined: -unknown-flag") assert.Contains(t, err.Error(), "USAGE:") @@ -318,7 +319,7 @@ func Test_PrintCorrectUsage(t *testing.T) { SetArgsForTest(t, []string{"my-program", "--unknown-value", "to_trigger_usage"}) - err := loadConfigWithArgs(&config{}, "my-program", os.Args) + _, err := loadConfigWithArgs(&config{}, "my-program", os.Args) require.Error(t, err) assert.Contains(t, err.Error(), "--documented-value string Description of the documented value [$DOCUMENTED_VALUE]") @@ -365,7 +366,7 @@ func Test_loadConfigDuplicates(t *testing.T) { t.Run(tt.name, func(t *testing.T) { SetArgsForTest(t, []string{"my-program"}) // no args set - err := loadConfigWithArgs(tt.cfg, "my-program", os.Args) + _, err := loadConfigWithArgs(tt.cfg, "my-program", os.Args) if tt.wantError != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantError) @@ -376,7 +377,7 @@ func Test_loadConfigDuplicates(t *testing.T) { } } -func Test_LoadAndValidateArgs(t *testing.T) { +func Test_LoadArgs(t *testing.T) { type config struct { Name string `validate:"required"` } @@ -384,11 +385,40 @@ func Test_LoadAndValidateArgs(t *testing.T) { SetArgsForTest(t, []string{"my-program"}) cfg := &config{} - err := LoadAndValidateArgs(cfg, "my-program", []string{"my-program", "--name", "Tilebox"}) + err := LoadArgs(cfg, "my-program", []string{"my-program", "--name", "Tilebox"}) require.NoError(t, err) assert.Equal(t, "Tilebox", cfg.Name) } +func Test_LoadArgsUsesCustomValidator(t *testing.T) { + type config struct { + Name string `validate:"required"` + } + + cfg := &config{} + customErr := errors.New("custom validation failed") + + err := LoadArgs(cfg, "my-program", []string{"my-program", "--name", "Tilebox"}, WithValidator(func(configPointer any) error { + assert.Same(t, cfg, configPointer) + assert.Equal(t, "Tilebox", cfg.Name) + return customErr + })) + require.ErrorIs(t, err, customErr) +} + +func Test_LoadArgsCustomValidatorReplacesDefaultValidator(t *testing.T) { + type config struct { + Name string `validate:"required"` + } + + cfg := &config{} + err := LoadArgs(cfg, "my-program", []string{"my-program"}, WithValidator(func(configPointer any) error { + assert.Same(t, cfg, configPointer) + return nil + })) + require.NoError(t, err) +} + func Test_NewCommandSubcommands(t *testing.T) { type greetConfig struct { Name string `default:"World"` @@ -435,24 +465,38 @@ func Test_NewCommandSubcommands(t *testing.T) { assert.Equal(t, 0, sumCfg.Right) } -func Test_BindCommandRejectsLoadConfigFlag(t *testing.T) { +func Test_BindCommandValidatesBeforeAction(t *testing.T) { type config struct { - Name string + Name string `validate:"required"` } - cmd := &cli.Command{Name: "greet"} - err := BindCommand(cmd, &config{}, WithDefaultLoadConfigFlag()) + cfg := &config{} + actionRan := false + cmd := &cli.Command{ + Name: "greet", + Action: func(ctx context.Context, cmd *cli.Command) error { + actionRan = true + return nil + }, + } + + err := BindCommand(cmd, cfg) + require.NoError(t, err) + + err = cmd.Run(context.Background(), []string{"greet"}) require.Error(t, err) - assert.Contains(t, err.Error(), "WithLoadConfigFlag is not supported") + assert.Contains(t, err.Error(), "Missing required configuration") + assert.False(t, actionRan) } -func Test_BindCommandValidatesBeforeAction(t *testing.T) { +func Test_BindCommandUsesCustomValidator(t *testing.T) { type config struct { Name string `validate:"required"` } cfg := &config{} actionRan := false + customErr := errors.New("custom command validation failed") cmd := &cli.Command{ Name: "greet", Action: func(ctx context.Context, cmd *cli.Command) error { @@ -461,12 +505,15 @@ func Test_BindCommandValidatesBeforeAction(t *testing.T) { }, } - err := BindCommand(cmd, cfg) + err := BindCommand(cmd, cfg, WithCommandValidator(func(configPointer any) error { + assert.Same(t, cfg, configPointer) + assert.Equal(t, "Tilebox", cfg.Name) + return customErr + })) require.NoError(t, err) - err = cmd.Run(context.Background(), []string{"greet"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "Missing required configuration") + err = cmd.Run(context.Background(), []string{"greet", "--name", "Tilebox"}) + require.ErrorIs(t, err, customErr) assert.False(t, actionRan) } diff --git a/toml.go b/toml.go index c4a6cf7..d6b2369 100644 --- a/toml.go +++ b/toml.go @@ -63,7 +63,7 @@ func (ms *mapSource) Lookup(name string) (any, bool) { case map[string]any: node = make(map[any]any, len(child)) for k, v := range child { - node[k] = v //nolint: modernize + node[k] = v } case map[any]any: node = child From d5f5731c924834fa3f106fd208008f2a46244ffa Mon Sep 17 00:00:00 2001 From: Lukas Bindreiter Date: Tue, 12 May 2026 14:23:24 +0200 Subject: [PATCH 2/2] Update ci --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 321b43e..539cb99 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [Ubuntu] - go-version: ["1.25.x"] + go-version: ["1.26.x"] runs-on: ${{ matrix.os }}-latest permissions: contents: read # for golangci-lint-action @@ -41,5 +41,5 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v9 with: - version: v2.7.2 + version: v2.12.2 args: --timeout=5m --verbose