diff --git a/README.md b/README.md index 22fca53..1e4076d 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,90 @@ $ ./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: + +```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 +``` + ### 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..22d46cc --- /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..2c48b2f 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 } @@ -101,7 +191,7 @@ func (e *helpRequestedError) Error() string { return e.helpText } -func loadConfig(configPointer any, programName string, opts ...Option) error { +func loadConfigWithArgs(configPointer any, programName string, args []string, opts ...Option) error { cfg := &options{} for _, opt := range opts { opt(cfg) @@ -152,7 +242,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 +290,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..80a19be 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) { @@ -57,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) @@ -113,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) @@ -220,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) @@ -262,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) @@ -299,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:") @@ -316,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]") @@ -363,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) @@ -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