From a17819749b82bdc3f55f65c355e3e7e39caa0bd4 Mon Sep 17 00:00:00 2001 From: Stefan Amberger <1277330+snamber@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:08:34 +0100 Subject: [PATCH 1/3] Make structconf subcommand ready Amp-Thread-ID: https://ampcode.com/threads/T-019ce42d-4224-751e-9b6c-22c02bb52e1c Co-authored-by: Amp --- README.md | 80 +++++++++++++++++++++++++ examples/09_subcommands/main.go | 72 ++++++++++++++++++++++ structconf.go | 102 ++++++++++++++++++++++++++++++-- structconf_test.go | 96 ++++++++++++++++++++++++++++++ 4 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 examples/09_subcommands/main.go diff --git a/README.md b/README.md index 22fca53..db4e43e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ go get github.com/tilebox/structconf - Using the tags `flag`, `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 @@ -201,6 +202,85 @@ $ ./app --load-config database.toml &{INFO {myuser mypassword}} ``` +### Build subcommands + +You can bind configs directly to `urfave/cli` commands and compose them as subcommands. + +```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 +} + +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) + } +} +``` + +`BindCommand` and `NewCommand` currently support flags, env vars and default values. `WithLoadConfigFlag` is currently only supported by `LoadAndValidate` / `MustLoadAndValidate`. + +### Parse custom arg slices + +If you need to parse a specific arg slice (for tests or embedding), use `LoadAndValidateArgs`: + +```go +cfg := &AppConfig{} +err := structconf.LoadAndValidateArgs(cfg, "app", []string{"app", "--log-level", "DEBUG"}) +if err != nil { + panic(err) +} +``` + +### Shell completion + +Enable completion in code: + +```go +structconf.MustLoadAndValidate(cfg, "app", structconf.WithShellCompletions()) +``` + +Then install it in your shell (example for fish): + +```bash +app completion fish > ~/.config/fish/completions/app.fish +``` + +For bash/zsh, source the generated script (`app completion bash` / `app completion zsh`). + ### Override auto-generated names for fields By default, field names are converted to flags, env vars and toml properties using the following rules: diff --git a/examples/09_subcommands/main.go b/examples/09_subcommands/main.go new file mode 100644 index 0000000..9044c95 --- /dev/null +++ b/examples/09_subcommands/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/tilebox/structconf" + "github.com/urfave/cli/v3" +) + +type GreetConfig struct { + Name string `default:"World" help:"Whom to greet"` + Loud bool `help:"Print in uppercase"` +} + +type SumConfig struct { + Left int `default:"1" help:"Left operand"` + Right int `default:"2" help:"Right operand"` +} + +// usage: +// ./subcommands greet --name Tilebox --loud +// ./subcommands sum --left 10 --right 5 +func main() { + appName := filepath.Base(os.Args[0]) + if appName == "" { + appName = "subcommands" + } + + 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 + }, structconf.WithDescription("Print a greeting")) + if err != nil { + panic(err) + } + + sumCfg := &SumConfig{} + sumCmd := &cli.Command{ + Name: "sum", + Usage: "Add two integers", + Action: func(ctx context.Context, cmd *cli.Command) error { + fmt.Println(sumCfg.Left + sumCfg.Right) + return nil + }, + } + + err = structconf.BindCommand(sumCmd, sumCfg) + if err != nil { + panic(err) + } + + root := &cli.Command{ + Name: appName, + Usage: "Example app showing structconf subcommands", + EnableShellCompletion: true, + Commands: []*cli.Command{greetCmd, sumCmd}, + } + + if err := root.Run(context.Background(), os.Args); err != nil { + panic(err) + } +} diff --git a/structconf.go b/structconf.go index bd0fc58..0c8cebd 100644 --- a/structconf.go +++ b/structconf.go @@ -58,7 +58,13 @@ func WithLoadConfigFlag(flagName string) Option { // MustLoadAndValidate is like LoadAndValidate, 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) { - err := LoadAndValidate(configPointer, programName, opts...) + MustLoadAndValidateArgs(configPointer, programName, os.Args, opts...) +} + +// MustLoadAndValidateArgs is like LoadAndValidateArgs, 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...) if err != nil { helpRequested := &helpRequestedError{} if errors.As(err, &helpRequested) { @@ -85,7 +91,12 @@ func MustLoadAndValidate(configPointer any, programName string, opts ...Option) // It then validates the loaded config, using the validate tag in config fields - if it fails, it returns an error. // The returned error is suitable to be printed to the user. func LoadAndValidate(configPointer any, programName string, opts ...Option) error { - err := loadConfig(configPointer, programName, opts...) + return LoadAndValidateArgs(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...) if err != nil { return err } @@ -93,6 +104,85 @@ func LoadAndValidate(configPointer any, programName string, opts ...Option) erro return validate(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. +// +// The WithLoadConfigFlag option is not currently supported for BindCommand/NewCommand. +func NewCommand(configPointer any, commandName string, action cli.ActionFunc, opts ...Option) (*cli.Command, error) { + cmd := &cli.Command{ + Name: commandName, + Action: action, + } + + err := BindCommand(cmd, configPointer, opts...) + if err != nil { + return nil, err + } + + return cmd, nil +} + +// BindCommand binds the given config struct to an existing urfave/cli command. +// +// It appends reflected flags to the command and wraps the command's Action so that config +// loading and validation 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{} + 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 + } + + flags := append([]cli.Flag{}, command.Flags...) + flags = append(flags, config.Flags()...) + + if duplicate := firstDuplicateFlagName(flags); duplicate != "" { + return fmt.Errorf("duplicate flag: --%s", duplicate) + } + + command.Flags = flags + 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 { + return err + } + + if wrappedAction == nil { + return nil + } + + return wrappedAction(ctx, cmd) + } + + return nil +} + type helpRequestedError struct { helpText string } @@ -102,6 +192,10 @@ func (e *helpRequestedError) Error() string { } func loadConfig(configPointer any, programName string, opts ...Option) error { + return loadConfigWithArgs(configPointer, programName, os.Args, opts...) +} + +func loadConfigWithArgs(configPointer any, programName string, args []string, opts ...Option) error { cfg := &options{} for _, opt := range opts { opt(cfg) @@ -152,7 +246,7 @@ func loadConfig(configPointer any, programName string, opts ...Option) error { }, } - err = cmd.Run(context.Background(), os.Args) + err = cmd.Run(context.Background(), args) if err != nil { if stdout.Len() > 0 { return errors.New(err.Error() + "\n\n" + stdout.String()) @@ -200,7 +294,7 @@ func loadConfig(configPointer any, programName string, opts ...Option) error { }, } - err = cmd.Run(context.Background(), os.Args) + err = cmd.Run(context.Background(), args) if err != nil { if stdout.Len() > 0 { return errors.New(strings.TrimSpace(err.Error() + "\n\n" + stdout.String())) diff --git a/structconf_test.go b/structconf_test.go index 100d157..afc43ab 100644 --- a/structconf_test.go +++ b/structconf_test.go @@ -1,6 +1,7 @@ package structconf import ( + "context" "os" "path" "slices" @@ -10,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) func Test_loadConfigFullyTagged(t *testing.T) { @@ -374,6 +376,100 @@ func Test_loadConfigDuplicates(t *testing.T) { } } +func Test_LoadAndValidateArgs(t *testing.T) { + type config struct { + Name string `validate:"required"` + } + + SetArgsForTest(t, []string{"my-program"}) + + cfg := &config{} + err := LoadAndValidateArgs(cfg, "my-program", []string{"my-program", "--name", "Tilebox"}) + require.NoError(t, err) + assert.Equal(t, "Tilebox", cfg.Name) +} + +func Test_NewCommandSubcommands(t *testing.T) { + type greetConfig struct { + Name string `default:"World"` + Loud bool + } + type sumConfig struct { + Left int + Right int + } + + greetCfg := &greetConfig{} + sumCfg := &sumConfig{} + + greetRan := false + sumRan := false + + greetCmd, err := NewCommand(greetCfg, "greet", func(ctx context.Context, cmd *cli.Command) error { + greetRan = true + if greetCfg.Loud { + greetCfg.Name = strings.ToUpper(greetCfg.Name) + } + return nil + }) + require.NoError(t, err) + + sumCmd, err := NewCommand(sumCfg, "sum", func(ctx context.Context, cmd *cli.Command) error { + sumRan = true + return nil + }) + require.NoError(t, err) + + root := &cli.Command{ + Name: "app", + Commands: []*cli.Command{greetCmd, sumCmd}, + } + + err = root.Run(context.Background(), []string{"app", "greet", "--name", "tilebox", "--loud"}) + require.NoError(t, err) + + assert.True(t, greetRan) + assert.False(t, sumRan) + assert.Equal(t, "TILEBOX", greetCfg.Name) + assert.Equal(t, 0, sumCfg.Left) + assert.Equal(t, 0, sumCfg.Right) +} + +func Test_BindCommandRejectsLoadConfigFlag(t *testing.T) { + type config struct { + Name string + } + + cmd := &cli.Command{Name: "greet"} + err := BindCommand(cmd, &config{}, WithDefaultLoadConfigFlag()) + require.Error(t, err) + assert.Contains(t, err.Error(), "WithLoadConfigFlag is not supported") +} + +func Test_BindCommandValidatesBeforeAction(t *testing.T) { + type config struct { + Name string `validate:"required"` + } + + 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(), "Missing required configuration") + assert.False(t, actionRan) +} + func SetArgsForTest(t *testing.T, args []string) { oldArgs := os.Args From 384e33dae8540c312bf5a0213991c5db2c67cb8c Mon Sep 17 00:00:00 2001 From: Stefan Amberger <1277330+snamber@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:17:10 +0100 Subject: [PATCH 2/3] Fix CI lint failures Amp-Thread-ID: https://ampcode.com/threads/T-019ce42d-4224-751e-9b6c-22c02bb52e1c Co-authored-by: Amp --- examples/09_subcommands/main.go | 2 +- structconf.go | 4 ---- structconf_test.go | 14 +++++++------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/examples/09_subcommands/main.go b/examples/09_subcommands/main.go index 9044c95..22d46cc 100644 --- a/examples/09_subcommands/main.go +++ b/examples/09_subcommands/main.go @@ -12,7 +12,7 @@ import ( ) type GreetConfig struct { - Name string `default:"World" help:"Whom to greet"` + Name string `default:"World" help:"Whom to greet"` Loud bool `help:"Print in uppercase"` } diff --git a/structconf.go b/structconf.go index 0c8cebd..2c48b2f 100644 --- a/structconf.go +++ b/structconf.go @@ -191,10 +191,6 @@ func (e *helpRequestedError) Error() string { return e.helpText } -func loadConfig(configPointer any, programName string, opts ...Option) error { - return loadConfigWithArgs(configPointer, programName, os.Args, opts...) -} - func loadConfigWithArgs(configPointer any, programName string, args []string, opts ...Option) error { cfg := &options{} for _, opt := range opts { diff --git a/structconf_test.go b/structconf_test.go index afc43ab..80a19be 100644 --- a/structconf_test.go +++ b/structconf_test.go @@ -59,7 +59,7 @@ func Test_loadConfigFullyTagged(t *testing.T) { SetArgsForTest(t, tt.args.cliArgs) // set cli args, and clean up after the test - err := loadConfig(config, "my-program", WithDefaultLoadConfigFlag()) + err := loadConfigWithArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) require.NoError(t, err) assert.Equal(t, tt.wantValue, config.Value) @@ -115,7 +115,7 @@ func Test_loadConfigDefaultTags(t *testing.T) { SetArgsForTest(t, tt.args.cliArgs) // set cli args, and clean up after the test - err := loadConfig(config, "my-program", WithDefaultLoadConfigFlag()) + err := loadConfigWithArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) require.NoError(t, err) assert.Equal(t, tt.wantValue, config.Value) @@ -222,7 +222,7 @@ duration = "1m5s" t.Setenv(key, value) // set env vars, and clean up after the test } - err := loadConfig(config, "my-program", WithDefaultLoadConfigFlag()) + err := loadConfigWithArgs(config, "my-program", os.Args, WithDefaultLoadConfigFlag()) require.NoError(t, err) assert.Equal(t, tt.wantValue, config.Value) @@ -264,7 +264,7 @@ second = "second_nested_config" SetArgsForTest(t, []string{"my-program", "--load-config", firstConfigPath + "," + secondConfigPath}) cfg := &config{} - err := loadConfig(cfg, "my-program", WithDefaultLoadConfigFlag()) + err := loadConfigWithArgs(cfg, "my-program", os.Args, WithDefaultLoadConfigFlag()) require.NoError(t, err) assert.Equal(t, "first_config", cfg.Value) @@ -301,7 +301,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 := loadConfig(tt.cfg, "my-program", 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 +318,7 @@ func Test_PrintCorrectUsage(t *testing.T) { SetArgsForTest(t, []string{"my-program", "--unknown-value", "to_trigger_usage"}) - err := loadConfig(&config{}, "my-program") + 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 +365,7 @@ func Test_loadConfigDuplicates(t *testing.T) { t.Run(tt.name, func(t *testing.T) { SetArgsForTest(t, []string{"my-program"}) // no args set - err := loadConfig(tt.cfg, "my-program") + err := loadConfigWithArgs(tt.cfg, "my-program", os.Args) if tt.wantError != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantError) From 994abb0bea5783a0372e7d58a7d00df5fd31e51a Mon Sep 17 00:00:00 2001 From: Stefan Amberger <1277330+snamber@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:22:25 +0100 Subject: [PATCH 3/3] Update shell completion examples Amp-Thread-ID: https://ampcode.com/threads/T-019ce42d-4224-751e-9b6c-22c02bb52e1c Co-authored-by: Amp --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index db4e43e..1e4076d 100644 --- a/README.md +++ b/README.md @@ -273,14 +273,19 @@ Enable completion in code: structconf.MustLoadAndValidate(cfg, "app", structconf.WithShellCompletions()) ``` -Then install it in your shell (example for fish): +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 ``` -For bash/zsh, source the generated script (`app completion bash` / `app completion zsh`). - ### Override auto-generated names for fields By default, field names are converted to flags, env vars and toml properties using the following rules: