Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
72 changes: 72 additions & 0 deletions examples/09_subcommands/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
100 changes: 95 additions & 5 deletions structconf.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -85,14 +91,98 @@ 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
}

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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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()))
Expand Down
Loading
Loading