diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3f72e1..ae7e3fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: "1.21" + go-version: "1.24" # HACK: actions doesn't support multiple modules in one repo for caching cache: false diff --git a/.gitignore b/.gitignore index 223cec9..a05040f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .coverprofile +cli/example/example diff --git a/Makefile b/Makefile index 8627fb5..3c0e4f9 100644 --- a/Makefile +++ b/Makefile @@ -7,16 +7,15 @@ GOTAGS = testing GO ?= $(shell which go) -export GOEXPERIMENT=nocoverageredesign +# GOEXPERIMENT=nocoverageredesign was removed in Go 1.25 .PHONY: test test: @for dir in $(SUBDIRS); do \ - cd $$dir && \ + (cd $$dir && \ $(GO) test -vet=off -tags='$(GOTAGS)' $(GOTESTFLAGS) -coverpkg="./..." -coverprofile=.coverprofile ./... && \ grep -v 'cmd' < .coverprofile > .covprof && mv .covprof .coverprofile && \ - $(GO) tool cover -func=.coverprofile && \ - cd .. ; \ + $(GO) tool cover -func=.coverprofile) || exit 1; \ done .PHONY: coverage @@ -35,13 +34,12 @@ version: .PHONY: lint lint: $(GOPATH)/bin/golangci-lint @for dir in $(SUBDIRS); do \ - cd $$dir && \ - golangci-lint run --timeout 5m . && \ - cd .. ; \ + (cd $$dir && \ + golangci-lint run --timeout 5m .) || exit 1; \ done $(GOPATH)/bin/golangci-lint: - $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.2 + $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 $(GOPATH)/bin/golines: $(GO) install github.com/segmentio/golines@latest diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..f9d6c09 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,240 @@ +# cli + +A struct-driven CLI framework for Go with a Configure-Validate-Run lifecycle. + +Designed as a simpler alternative to Cobra+Viper, with native integration with +[`loader`](../loader) (config file loading with HuJSON + env var replacement) +and [`await`](../await) (goroutine lifecycle and graceful shutdown). + +## Quick Start + +```go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/runreveal/lib/cli" +) + +type ServeCmd struct { + Addr string `cli:"addr,a" usage:"listen address" default:":8080"` +} + +func (s *ServeCmd) Run(ctx context.Context, args []string) error { + fmt.Printf("serving on %s\n", s.Addr) + return nil +} + +func main() { + app := cli.New("myapp", "My application") + app.AddCommand(cli.Command("serve", "Start the server", &ServeCmd{})) + os.Exit(app.Run(context.Background(), os.Args[1:])) +} +``` + +## Features + +### Struct Tags + +Command structs use tags to define flags and config bindings: + +```go +type ServeCmd struct { + Addr string `cli:"addr,a" usage:"listen address" default:":8080"` + Timeout time.Duration `cli:"timeout,t" usage:"request timeout" default:"30s"` + DB DBConfig `config:"database"` // loaded from config file +} +``` + +| Tag | Purpose | +|---|---| +| `cli:"name,alias"` | Flag name and optional single-char alias | +| `cli:"-"` | Skip this field | +| `usage:"text"` | Help text | +| `default:"value"` | Default value (parsed to field type) | +| `config:"key"` | JSON path in config file to unmarshal into this field | + +Supported types: `string`, `bool`, `int`, `int64`, `uint`, `uint64`, `float64`, +`time.Duration`, `[]string`, pointer variants, and `encoding.TextUnmarshaler`. + +### Global Flags + +Define shared flags once at the app level instead of embedding in every command: + +```go +type Globals struct { + Verbose bool `cli:"verbose,v" usage:"enable verbose output"` + Config string `cli:"config,c" usage:"config file" default:"config.json"` +} + +globals := &Globals{} +app := cli.New("myapp", "desc", + cli.WithGlobals(globals), + cli.WithConfigFlag("config"), +) + +// Access in any handler: +func (s *ServeCmd) Run(ctx context.Context, args []string) error { + g := cli.GlobalsFromContext[Globals](ctx) + if g.Verbose { ... } +} +``` + +### Configure-Validate-Run Lifecycle + +Globals and handlers can implement optional lifecycle interfaces: + +```go +// Configurer is called after config loading to initialize resources. +type Configurer interface { + Configure() error +} + +// Validator is called after Configure to check readiness. +type Validator interface { + Validate() error +} +``` + +The framework also calls `io.Closer` on globals after the command exits. + +**Lifecycle order:** +1. Parse flags (globals + handler) +2. Load config file into struct fields +3. Globals: `Configure()` -> `Validate()` -> defer `Close()` +4. Handler: `Configure()` -> `Validate()` +5. Middleware -> `handler.Run(ctx, args)` +6. Globals `Close()` + +### Config File Loading + +One config file for the whole app. Commands declare which sections they need +using `config:"key"` struct tags: + +```go +type ServeCmd struct { + DB DBConfig `config:"database"` +} +``` + +Config files are processed through `loader.LoadConfig`, which supports HuJSON +(comments, trailing commas) and `$ENV_VAR` replacement in string values. + +**Precedence:** explicit CLI flag > config file > default tag > zero value + +### Commands and Groups + +```go +app.AddCommand( + // Command with handler + cli.Command("serve", "Start the server", &ServeCmd{}), + + // Command with handler AND subcommands + cli.Command("admin", "Admin tools", &AdminCmd{}, + cli.Command("migrate", "Run migrations", &MigrateCmd{}), + ), + + // Group (no handler, prints help when invoked directly) + cli.Group("db", "Database commands", + cli.Command("migrate", "Run migrations", &MigrateCmd{}), + cli.Command("seed", "Seed data", &SeedCmd{}), + ), +) +``` + +### Middleware + +```go +app := cli.New("myapp", "desc", + cli.WithMiddleware(func(ctx context.Context, info cli.CommandInfo, next func(context.Context) error) error { + slog.Info("running", "command", info.Name) + return next(ctx) + }), +) +``` + +### Args Validation + +```go +cli.Command("get", "Get a resource", &GetCmd{}, cli.WithArgs(cli.ExactArgs(1))) +cli.Command("run", "Run a task", &RunCmd{}, cli.WithArgs(cli.NoArgs)) +cli.Command("ping", "Ping hosts", &PingCmd{}, cli.WithArgs(cli.MinArgs(1))) +``` + +### Await Integration + +For long-running services, use `await` in your handler's `Run` method: + +```go +func (s *ServeCmd) Run(ctx context.Context, args []string) error { + server := &http.Server{Addr: s.Addr, Handler: mux} + + w := await.New(await.WithSignals) + w.AddNamed(await.ListenAndServe(server), "http") + return w.Run(ctx) +} +``` + +For multiple services under one process: + +```go +func (d *DaemonCmd) Run(ctx context.Context, args []string) error { + w := await.New(await.WithSignals, await.WithStopTimeout(15*time.Second)) + w.AddNamed(await.ListenAndServe(apiServer), "api") + w.AddNamed(await.ListenAndServe(metricServer), "metrics") + return w.Run(ctx) +} +``` + +### Shell Completion + +```bash +# Generate completion script +eval "$(myapp completion bash)" # or zsh, fish +``` + +Handlers can provide custom completions for positional args: + +```go +func (g *GetCmd) Complete(ctx context.Context, args []string) []cli.Completion { + return []cli.Completion{ + {Value: "pods", Description: "list pods"}, + {Value: "services", Description: "list services"}, + } +} +``` + +### Default Config + +Ship a reference config with your binary using `go:embed`: + +```go +//go:embed config.json +var defaultConfig []byte + +app := cli.New("myapp", "desc", + cli.WithDefaultConfig(defaultConfig), +) +``` + +```bash +myapp defcon > config.json # dump the default config +``` + +The command name is `defcon` by default, overridable with `WithDefaultConfigCommand`. + +### Built-in Flags + +| Flag | Behavior | +|---|---| +| `-h`, `--help` | Print help for the app or command | +| `--version` | Print version (requires `WithVersion`) | + +## See Also + +- [`await`](../await) — goroutine lifecycle management +- [`loader`](../loader) — polymorphic config loading +- [`cli/example`](./example) — complete working example diff --git a/cli/args.go b/cli/args.go new file mode 100644 index 0000000..a731e54 --- /dev/null +++ b/cli/args.go @@ -0,0 +1,34 @@ +package cli + +import "fmt" + +// ArgsFunc validates positional arguments. +type ArgsFunc func(args []string) error + +// NoArgs returns an error if any positional args are present. +func NoArgs(args []string) error { + if len(args) > 0 { + return fmt.Errorf("unexpected arguments: %v", args) + } + return nil +} + +// ExactArgs returns an ArgsFunc that requires exactly n positional args. +func ExactArgs(n int) ArgsFunc { + return func(args []string) error { + if len(args) != n { + return fmt.Errorf("expected exactly %d argument(s), got %d", n, len(args)) + } + return nil + } +} + +// MinArgs returns an ArgsFunc that requires at least n positional args. +func MinArgs(n int) ArgsFunc { + return func(args []string) error { + if len(args) < n { + return fmt.Errorf("expected at least %d argument(s), got %d", n, len(args)) + } + return nil + } +} diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..b54c2ca --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,546 @@ +// Package cli provides a simple, struct-driven CLI framework. +// It follows a Configure-Validate-Run lifecycle and integrates +// with github.com/runreveal/lib/loader for config file loading. +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "reflect" + "runtime/debug" + "strings" +) + +// flagSetKey is the context key used to carry the *FlagSet during command execution. +type flagSetKey struct{} + +// globalsKey is the context key used to carry the globals pointer during execution. +type globalsKey struct{} + +// Runnable is the core interface every command handler must implement. +type Runnable interface { + Run(ctx context.Context, args []string) error +} + +// Configurer is optionally implemented by globals or handler structs. +// Called after config file loading to initialize resources (e.g. open +// database connections, create clients). +type Configurer interface { + Configure() error +} + +// Validator is optionally implemented by globals or handler structs. +// Called after Configure to check that the fully-loaded config is valid. +type Validator interface { + Validate() error +} + +// HelpExtra is optionally implemented by command handlers to append +// additional information to help output. Useful for showing available +// loader types, config file schemas, or other context that the cli +// framework can't derive from struct tags alone. +type HelpExtra interface { + ExtraHelp() string +} + +// ExitError carries a custom exit code. +type ExitError struct { + Code int + Err error +} + +func (e *ExitError) Error() string { + if e.Err != nil { + return e.Err.Error() + } + return fmt.Sprintf("exit code %d", e.Code) +} + +func (e *ExitError) Unwrap() error { return e.Err } + +// CommandInfo is passed to middleware. +type CommandInfo struct { + Name string // full command path, e.g. "admin migrate" + Args []string // positional args after flag parsing +} + +// Middleware wraps command execution. +type Middleware func(ctx context.Context, info CommandInfo, next func(context.Context) error) error + +// Node is a node in the command tree. +type Node interface { + nodeName() string + nodeDesc() string + isGroup() bool +} + +type commandNode struct { + name string + desc string + handler Runnable + children []Node + opts cmdOptions +} + +func (c *commandNode) nodeName() string { return c.name } +func (c *commandNode) nodeDesc() string { return c.desc } +func (c *commandNode) isGroup() bool { return false } + +type groupNode struct { + name string + desc string + children []Node +} + +func (g *groupNode) nodeName() string { return g.name } +func (g *groupNode) nodeDesc() string { return g.desc } +func (g *groupNode) isGroup() bool { return true } + +// CmdOption configures a Command node. +type CmdOption func(*cmdOptions) + +type cmdOptions struct { + argsFunc ArgsFunc +} + +// WithArgs sets an args validation function on a command. +func WithArgs(f ArgsFunc) CmdOption { + return func(o *cmdOptions) { o.argsFunc = f } +} + +// Command creates a command node. Each element of opts may be a Node (child +// subcommand) or a CmdOption (behavioural option); they are distinguished by +// type at runtime. +func Command(name, desc string, handler Runnable, opts ...any) Node { + o := cmdOptions{} + var children []Node + for _, opt := range opts { + switch v := opt.(type) { + case Node: + children = append(children, v) + case CmdOption: + v(&o) + default: + panic(fmt.Sprintf("cli.Command %q: unsupported option type %T", name, v)) + } + } + return &commandNode{name: name, desc: desc, handler: handler, children: children, opts: o} +} + +// Group creates a group node that only prints help when invoked directly. +func Group(name, desc string, children ...Node) Node { + return &groupNode{name: name, desc: desc, children: children} +} + +// AppOption configures an App. +type AppOption func(*App) + +// WithVersion sets the application version (enables --version flag). +func WithVersion(v string) AppOption { + return func(a *App) { a.version = v } +} + +// WithMiddleware adds a middleware to the app. +func WithMiddleware(m Middleware) AppOption { + return func(a *App) { a.middlewares = append(a.middlewares, m) } +} + +// WithConfigFlag sets which flag name holds the config file path. +func WithConfigFlag(flagName string) AppOption { + return func(a *App) { a.configFlag = flagName } +} + +// WithGlobals registers a struct pointer whose cli-tagged fields become +// flags available on every command. The pointer is stored in context and +// can be retrieved with GlobalsFromContext. +func WithGlobals(ptr any) AppOption { + return func(a *App) { a.globals = ptr } +} + +// WithDefaultConfig registers a default configuration that can be printed +// with "myapp defcon". Typically used with go:embed to ship a +// reference config alongside the binary. The command name defaults to +// "defcon" but can be overridden with WithDefaultConfigCommand. +func WithDefaultConfig(data []byte) AppOption { + return func(a *App) { a.defaultConfig = data } +} + +// WithDefaultConfigCommand overrides the command name used to print the +// default config (default: "defcon"). +func WithDefaultConfigCommand(name string) AppOption { + return func(a *App) { a.defaultConfigCmd = name } +} + +// WithOutput sets the writer for all output: help/errors (normally stderr) +// and completion/defcon (normally stdout). Useful for testing. +func WithOutput(w io.Writer) AppOption { + return func(a *App) { a.output = w; a.stdout = w } +} + +// App is the top-level CLI application. +type App struct { + name string + desc string + version string + configFlag string + globals any // pointer to globals struct, if set + defaultConfig []byte + defaultConfigCmd string + middlewares []Middleware + children []Node + output io.Writer // stderr: errors, help + stdout io.Writer // stdout: completion, defcon +} + +// New creates a new App. +func New(name, desc string, opts ...AppOption) *App { + a := &App{ + name: name, + desc: desc, + output: os.Stderr, + stdout: os.Stdout, + } + for _, opt := range opts { + opt(a) + } + return a +} + +// AddCommand adds top-level command nodes to the app. +func (a *App) AddCommand(nodes ...Node) { + a.children = append(a.children, nodes...) +} + +// Run executes the CLI with the given args (typically os.Args[1:]). +// Returns an exit code. +func (a *App) Run(ctx context.Context, args []string) (exitCode int) { + defer func() { + if r := recover(); r != nil { + slog.Error("panic in command", "panic", r, "stack", string(debug.Stack())) + exitCode = 1 + } + }() + + code, err := a.run(ctx, args) + if err != nil { + var exitErr *ExitError + if errors.As(err, &exitErr) { + if exitErr.Err != nil { + fmt.Fprintf(a.output, "error: %s\n", exitErr.Err) + } + return exitErr.Code + } + fmt.Fprintf(a.output, "error: %s\n", err) + return 1 + } + return code +} + +func (a *App) run(ctx context.Context, args []string) (int, error) { + // Handle built-in commands before normal routing so they stay + // hidden from help output and don't interfere with user commands. + if code, handled := a.handleCompletion(args); handled { + return code, nil + } + if dc := a.defconCmd(); dc != "" && len(args) >= 1 && args[0] == dc { + return a.handleDefaultConfig(), nil + } + + // Check for top-level --version / --help before routing + if len(args) == 1 && (args[0] == "--version" || args[0] == "-version") { + if a.version != "" { + fmt.Fprintf(a.output, "%s version %s\n", a.name, a.version) + } else { + fmt.Fprintf(a.output, "%s (no version set)\n", a.name) + } + return 0, nil + } + if len(args) == 0 || (len(args) == 1 && (args[0] == "--help" || args[0] == "-h")) { + printAppHelp(a.output, a.name, a.desc, a.children, a.version, a.defconCmd()) + return 0, nil + } + + node, rest, path := routeArgsWithPath(a.children, args, "") + if node == nil { + // Unknown command + fmt.Fprintf(a.output, "unknown command %q\n\n", args[0]) + printAppHelp(a.output, a.name, a.desc, a.children, a.version, a.defconCmd()) + return 1, nil + } + + return a.executeNode(ctx, node, rest, path) +} + +func routeArgsWithPath(children []Node, args []string, prefix string) (Node, []string, string) { + if len(args) == 0 { + return nil, args, prefix + } + + name := args[0] + // Don't treat flags as command names + if strings.HasPrefix(name, "-") { + return nil, args, prefix + } + + for _, child := range children { + if child.nodeName() == name { + fullPath := name + if prefix != "" { + fullPath = prefix + " " + name + } + rest := args[1:] + + // If this node has children and the next arg matches one, recurse + var subChildren []Node + switch n := child.(type) { + case *commandNode: + subChildren = n.children + case *groupNode: + subChildren = n.children + } + + if len(subChildren) > 0 && len(rest) > 0 && !strings.HasPrefix(rest[0], "-") { + if sub, subRest, subPath := routeArgsWithPath(subChildren, rest, fullPath); sub != nil { + return sub, subRest, subPath + } + } + + return child, rest, fullPath + } + } + return nil, args, prefix +} + +func (a *App) executeNode(ctx context.Context, node Node, args []string, path string) (int, error) { + switch n := node.(type) { + case *groupNode: + // Groups print help when invoked directly (no matching subcommand) + printGroupHelp(a.output, a.name, path, n.desc, n.children) + return 0, nil + + case *commandNode: + return a.executeCommand(ctx, n, args, path) + } + return 1, fmt.Errorf("unknown node type") +} + +func (a *App) executeCommand(ctx context.Context, node *commandNode, args []string, path string) (int, error) { + handler := node.handler + + // Check for --help before doing anything else + for _, arg := range args { + if arg == "--help" || arg == "-h" { + printCommandHelp(a.output, a.name, path, node.desc, handler, node.children, a.globals) + return 0, nil + } + if arg == "--" { + break + } + } + + // Build flag set from handler struct tags (also returns pre-scanned fields). + fs, fields, err := buildFlagSet(handler) + if err != nil { + return 1, fmt.Errorf("building flags for %s: %w", path, err) + } + + // If globals are set, merge their flags into the same flag set. + var globalFields []fieldInfo + if a.globals != nil { + var gf []fieldInfo + gf, err = addGlobalsToFlagSet(fs, a.globals) + if err != nil { + return 1, fmt.Errorf("building global flags: %w", err) + } + globalFields = gf + } + + // Set defaults (handler fields + global fields) + if err := applyDefaults(fs, fields); err != nil { + return 1, fmt.Errorf("applying defaults for %s: %w", path, err) + } + if err := applyDefaults(fs, globalFields); err != nil { + return 1, fmt.Errorf("applying global defaults: %w", err) + } + + // Parse flags + posArgs, err := fs.Parse(args) + if err != nil { + fmt.Fprintf(a.output, "error: %s\n\n", err) + printCommandHelp(a.output, a.name, path, node.desc, handler, node.children, a.globals) + return 1, nil + } + + // Carry the FlagSet and globals in context. + ctx = context.WithValue(ctx, flagSetKey{}, fs) + if a.globals != nil { + ctx = context.WithValue(ctx, globalsKey{}, a.globals) + } + + // Load config file if configured + if a.configFlag != "" { + configJSON, err := resolveConfigJSON(handler, a.globals, fs, a.configFlag, globalFields, fields) + if err != nil { + return 1, fmt.Errorf("loading config: %w", err) + } + if configJSON != "" { + // Apply config tags on globals + if a.globals != nil { + if err := applyConfigTags(a.globals, fs, globalFields, configJSON); err != nil { + return 1, fmt.Errorf("loading config: %w", err) + } + } + // Apply config:"key" struct tags on handler + if err := applyConfigTags(handler, fs, fields, configJSON); err != nil { + return 1, fmt.Errorf("loading config: %w", err) + } + } + } + + // CVR lifecycle on globals: Configure → Validate + if a.globals != nil { + if c, ok := a.globals.(Configurer); ok { + if err := c.Configure(); err != nil { + return 1, fmt.Errorf("globals configure: %w", err) + } + } + if v, ok := a.globals.(Validator); ok { + if err := v.Validate(); err != nil { + return 1, fmt.Errorf("globals validate: %w", err) + } + } + // Defer cleanup if globals implements io.Closer + if cl, ok := a.globals.(io.Closer); ok { + defer cl.Close() + } + } + + // CVR lifecycle on handler: Configure → Validate + if c, ok := handler.(Configurer); ok { + if err := c.Configure(); err != nil { + return 1, fmt.Errorf("configure: %w", err) + } + } + if v, ok := handler.(Validator); ok { + if err := v.Validate(); err != nil { + return 1, fmt.Errorf("validate: %w", err) + } + } + + // Validate args + if node.opts.argsFunc != nil { + if err := node.opts.argsFunc(posArgs); err != nil { + return 1, err + } + } + + // Build middleware chain + runFn := func(ctx context.Context) error { + return handler.Run(ctx, posArgs) + } + + info := CommandInfo{Name: path, Args: posArgs} + chain := buildChain(a.middlewares, info, runFn) + + // Errors (including ExitError) propagate to App.Run which handles exit codes. + if err := chain(ctx); err != nil { + return 1, err + } + return 0, nil +} + +func buildChain(middlewares []Middleware, info CommandInfo, final func(context.Context) error) func(context.Context) error { + if len(middlewares) == 0 { + return final + } + // Build from the inside out + chain := final + for i := len(middlewares) - 1; i >= 0; i-- { + m := middlewares[i] + next := chain + chain = func(ctx context.Context) error { + return m(ctx, info, next) + } + } + return chain +} + +// defconCmd returns the default config command name, or "" if no +// default config is registered. +func (a *App) defconCmd() string { + if len(a.defaultConfig) == 0 { + return "" + } + if a.defaultConfigCmd != "" { + return a.defaultConfigCmd + } + return "defcon" +} + +func (a *App) handleDefaultConfig() int { + if len(a.defaultConfig) == 0 { + fmt.Fprintf(a.output, "no default config registered\n") + return 1 + } + if _, err := a.stdout.Write(a.defaultConfig); err != nil { + fmt.Fprintf(a.output, "error writing config: %s\n", err) + return 1 + } + return 0 +} + +// FlagSetFromContext returns the *FlagSet stored in ctx during command +// execution, or nil if called outside of a command handler. +func FlagSetFromContext(ctx context.Context) *FlagSet { + fs, _ := ctx.Value(flagSetKey{}).(*FlagSet) + return fs +} + +// GlobalsFromContext retrieves the globals pointer from context, cast to *T. +// Returns nil if no globals were registered or the type doesn't match. +func GlobalsFromContext[T any](ctx context.Context) *T { + v := ctx.Value(globalsKey{}) + if v == nil { + return nil + } + if g, ok := v.(*T); ok { + return g + } + return nil +} + +// IsSet reports whether a flag was explicitly set on the command line. +// Must be called from within Run to return meaningful results. +func IsSet(ctx context.Context, flagName string) bool { + if fs := FlagSetFromContext(ctx); fs != nil { + return fs.IsSet(flagName) + } + return false +} + +// DumpConfig returns the resolved configuration of handler as a map of +// flag name → current field value. It reflects directly over the handler +// struct, so it captures values set by both CLI flags and config files. +func DumpConfig(handler Runnable) map[string]any { + rv := reflect.ValueOf(handler) + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + fields, err := scanFields(rv.Type()) + if err != nil { + return nil + } + m := make(map[string]any, len(fields)) + for _, fi := range fields { + if fi.flagLong == "" { + continue + } + fieldVal := fieldByIndex(rv, fi.fieldIndex) + m[fi.flagLong] = fieldVal.Interface() + } + return m +} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 0000000..1cdcf02 --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,1352 @@ +package cli_test + +import ( + "bytes" + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/runreveal/lib/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- helpers --- + +type echoCmd struct { + Message string `cli:"message,m" usage:"message to echo" default:"hello"` + Count int `cli:"count,n" usage:"number of times" default:"1"` +} + +func (e *echoCmd) Run(_ context.Context, _ []string) error { return nil } + +type boolCmd struct { + Verbose bool `cli:"verbose,v" usage:"be verbose"` + Debug bool `cli:"debug,d" usage:"debug mode"` + Name string `cli:"name" usage:"name"` +} + +func (b *boolCmd) Run(_ context.Context, _ []string) error { return nil } + +type noopCmd struct{} + +func (n *noopCmd) Run(_ context.Context, _ []string) error { return nil } + +type errCmd struct{} + +func (e *errCmd) Run(_ context.Context, _ []string) error { + return errors.New("run failed") +} + +type captureArgsCmd struct { + inner func([]string) +} + +func (c *captureArgsCmd) Run(_ context.Context, args []string) error { + c.inner(args) + return nil +} + +type panicCmd struct{} + +func (p *panicCmd) Run(_ context.Context, _ []string) error { + panic("oh no") +} + +type exitCodeCmd struct{ code int } + +func (e *exitCodeCmd) Run(_ context.Context, _ []string) error { + return &cli.ExitError{Code: e.code, Err: errors.New("custom exit")} +} + +type validateCmd struct { + Value string `cli:"value" usage:"a value"` + valid bool +} + +func (v *validateCmd) Validate() error { + if v.Value == "" { + return errors.New("value is required") + } + v.valid = true + return nil +} +func (v *validateCmd) Run(_ context.Context, _ []string) error { + if !v.valid { + return errors.New("Validate was not called") + } + return nil +} + +type isSetCmd struct { + Name string `cli:"name" usage:"name" default:"default"` + nameWasSet bool +} + +func (i *isSetCmd) Run(ctx context.Context, _ []string) error { + i.nameWasSet = cli.IsSet(ctx, "name") + return nil +} + +// writeConfigFile writes JSON content to a temp file and returns its path. +func writeConfigFile(t *testing.T, content string) string { + t.Helper() + tmp := t.TempDir() + "/config.json" + require.NoError(t, os.WriteFile(tmp, []byte(content), 0600)) + return tmp +} + +// --- flag parsing tests --- + +func TestFlagParsing_LongFlag(t *testing.T) { + cmd := &echoCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("echo", "echo", cmd)) + + code := app.Run(context.Background(), []string{"echo", "--message", "world", "--count", "3"}) + assert.Equal(t, 0, code) + assert.Equal(t, "world", cmd.Message) + assert.Equal(t, 3, cmd.Count) +} + +func TestFlagParsing_ShortFlag(t *testing.T) { + cmd := &echoCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("echo", "echo", cmd)) + + code := app.Run(context.Background(), []string{"echo", "-m", "hi", "-n", "2"}) + assert.Equal(t, 0, code) + assert.Equal(t, "hi", cmd.Message) + assert.Equal(t, 2, cmd.Count) +} + +func TestFlagParsing_EqualsSyntax(t *testing.T) { + cmd := &echoCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("echo", "echo", cmd)) + + code := app.Run(context.Background(), []string{"echo", "--message=greet", "--count=5"}) + assert.Equal(t, 0, code) + assert.Equal(t, "greet", cmd.Message) + assert.Equal(t, 5, cmd.Count) +} + +func TestFlagParsing_ShortEqualsSyntax(t *testing.T) { + cmd := &echoCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("echo", "echo", cmd)) + + code := app.Run(context.Background(), []string{"echo", "-m=yo"}) + assert.Equal(t, 0, code) + assert.Equal(t, "yo", cmd.Message) +} + +func TestFlagParsing_CombinedBoolShorts(t *testing.T) { + cmd := &boolCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", cmd)) + + code := app.Run(context.Background(), []string{"run", "-vd"}) + assert.Equal(t, 0, code) + assert.True(t, cmd.Verbose) + assert.True(t, cmd.Debug) +} + +func TestFlagParsing_DoubleDashSeparator(t *testing.T) { + var capturedArgs []string + handler := &captureArgsCmd{inner: func(args []string) { capturedArgs = args }} + + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", handler)) + + code := app.Run(context.Background(), []string{"run", "--", "pos1", "pos2"}) + assert.Equal(t, 0, code) + assert.Equal(t, []string{"pos1", "pos2"}, capturedArgs) +} + +func TestFlagParsing_UnknownFlag(t *testing.T) { + cmd := &echoCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("echo", "echo", cmd)) + + code := app.Run(context.Background(), []string{"echo", "--unknown"}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "unknown flag") +} + +func TestFlagParsing_Defaults(t *testing.T) { + cmd := &echoCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("echo", "echo", cmd)) + + code := app.Run(context.Background(), []string{"echo"}) + assert.Equal(t, 0, code) + assert.Equal(t, "hello", cmd.Message) + assert.Equal(t, 1, cmd.Count) +} + +// --- struct tag scanning tests --- + +type embeddedGlobals struct { + Verbose bool `cli:"verbose,v" usage:"verbose mode"` + Config string `cli:"config,c" usage:"config file" default:"config.json"` +} + +type embeddedCmd struct { + embeddedGlobals + Port int `cli:"port,p" usage:"port number" default:"8080"` +} + +func (e *embeddedCmd) Run(_ context.Context, _ []string) error { return nil } + +func TestStructTags_EmbeddedStruct(t *testing.T) { + cmd := &embeddedCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("serve", "serve", cmd)) + + code := app.Run(context.Background(), []string{"serve", "--verbose", "--port", "9090"}) + assert.Equal(t, 0, code) + assert.True(t, cmd.Verbose) + assert.Equal(t, 9090, cmd.Port) + assert.Equal(t, "config.json", cmd.Config) // default +} + +type skipCmd struct { + Name string `cli:"name" usage:"name"` + Ignored string `cli:"-"` + Also string // no tag, should be ignored +} + +func (s *skipCmd) Run(_ context.Context, _ []string) error { return nil } + +func TestStructTags_SkipField(t *testing.T) { + cmd := &skipCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", cmd)) + + code := app.Run(context.Background(), []string{"run", "--ignored", "val"}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "unknown flag") +} + +type ptrCmd struct { + Name *string `cli:"name" usage:"name"` + Port *int `cli:"port" usage:"port"` +} + +func (p *ptrCmd) Run(_ context.Context, _ []string) error { return nil } + +func TestStructTags_PointerFields(t *testing.T) { + cmd := &ptrCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", cmd)) + + code := app.Run(context.Background(), []string{"run", "--name", "alice", "--port", "3000"}) + assert.Equal(t, 0, code) + require.NotNil(t, cmd.Name) + assert.Equal(t, "alice", *cmd.Name) + require.NotNil(t, cmd.Port) + assert.Equal(t, 3000, *cmd.Port) +} + +type allTypesCmd struct { + Str string `cli:"str" default:"s"` + Bool bool `cli:"bool"` + I int `cli:"int" default:"1"` + I64 int64 `cli:"i64" default:"2"` + U uint `cli:"uint" default:"3"` + U64 uint64 `cli:"u64" default:"4"` + F float64 `cli:"flt" default:"1.5"` + Dur time.Duration `cli:"dur" default:"5s"` + Strs []string `cli:"strs"` +} + +func (a *allTypesCmd) Run(_ context.Context, _ []string) error { return nil } + +func TestStructTags_AllTypes(t *testing.T) { + cmd := &allTypesCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", cmd)) + + code := app.Run(context.Background(), []string{ + "run", + "--str", "hello", + "--bool", + "--int", "10", + "--i64", "20", + "--uint", "30", + "--u64", "40", + "--flt", "3.14", + "--dur", "10s", + "--strs", "a", + "--strs", "b", + }) + assert.Equal(t, 0, code) + assert.Equal(t, "hello", cmd.Str) + assert.True(t, cmd.Bool) + assert.Equal(t, 10, cmd.I) + assert.Equal(t, int64(20), cmd.I64) + assert.Equal(t, uint(30), cmd.U) + assert.Equal(t, uint64(40), cmd.U64) + assert.InDelta(t, 3.14, cmd.F, 0.001) + assert.Equal(t, 10*time.Second, cmd.Dur) + assert.Equal(t, []string{"a", "b"}, cmd.Strs) +} + +// --- command routing tests --- + +func TestRouting_Subcommand(t *testing.T) { + var called string + sub := &captureArgsCmd{inner: func(_ []string) { called = "sub" }} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("parent", "parent", &noopCmd{}, + cli.Command("child", "child", sub), + )) + + code := app.Run(context.Background(), []string{"parent", "child"}) + assert.Equal(t, 0, code) + assert.Equal(t, "sub", called) +} + +func TestRouting_GroupPrintsHelp(t *testing.T) { + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Group("admin", "admin commands", + cli.Command("migrate", "run migrations", &noopCmd{}), + )) + + code := app.Run(context.Background(), []string{"admin"}) + assert.Equal(t, 0, code) + assert.Contains(t, buf.String(), "migrate") +} + +func TestRouting_UnknownCommand(t *testing.T) { + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("serve", "serve", &noopCmd{})) + + code := app.Run(context.Background(), []string{"unknown"}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "unknown command") +} + +func TestRouting_NestedGroup(t *testing.T) { + var called bool + sub := &captureArgsCmd{inner: func(_ []string) { called = true }} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Group("a", "a", + cli.Group("b", "b", + cli.Command("c", "c", sub), + ), + )) + + code := app.Run(context.Background(), []string{"a", "b", "c"}) + assert.Equal(t, 0, code) + assert.True(t, called) +} + +// --- lifecycle tests --- + +func TestLifecycle_ValidateCalled(t *testing.T) { + cmd := &validateCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", cmd)) + + code := app.Run(context.Background(), []string{"run", "--value", "set"}) + assert.Equal(t, 0, code) +} + +func TestLifecycle_ValidateError(t *testing.T) { + cmd := &validateCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", cmd)) + + code := app.Run(context.Background(), []string{"run"}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "value is required") +} + +func TestLifecycle_MiddlewareOrdering(t *testing.T) { + var order []string + mkMiddleware := func(name string) cli.Middleware { + return func(ctx context.Context, info cli.CommandInfo, next func(context.Context) error) error { + order = append(order, name+":before") + err := next(ctx) + order = append(order, name+":after") + return err + } + } + + var buf bytes.Buffer + app := cli.New("app", "test", + cli.WithOutput(&buf), + cli.WithMiddleware(mkMiddleware("first")), + cli.WithMiddleware(mkMiddleware("second")), + ) + app.AddCommand(cli.Command("run", "run", &noopCmd{})) + + code := app.Run(context.Background(), []string{"run"}) + assert.Equal(t, 0, code) + assert.Equal(t, []string{"first:before", "second:before", "second:after", "first:after"}, order) +} + +func TestLifecycle_RunError(t *testing.T) { + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", &errCmd{})) + + code := app.Run(context.Background(), []string{"run"}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "run failed") +} + +func TestLifecycle_PanicRecovery(t *testing.T) { + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", &panicCmd{})) + + code := app.Run(context.Background(), []string{"run"}) + assert.Equal(t, 1, code) +} + +func TestLifecycle_ExitError(t *testing.T) { + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", &exitCodeCmd{code: 42})) + + code := app.Run(context.Background(), []string{"run"}) + assert.Equal(t, 42, code) +} + +// --- help output tests --- + +func TestHelp_AppHelp(t *testing.T) { + var buf bytes.Buffer + app := cli.New("myapp", "My application", cli.WithOutput(&buf), cli.WithVersion("1.0")) + app.AddCommand(cli.Command("serve", "Start HTTP server", &noopCmd{})) + + code := app.Run(context.Background(), []string{"--help"}) + assert.Equal(t, 0, code) + out := buf.String() + assert.Contains(t, out, "myapp") + assert.Contains(t, out, "serve") + assert.Contains(t, out, "--help") +} + +func TestHelp_CommandHelp(t *testing.T) { + cmd := &echoCmd{} + var buf bytes.Buffer + app := cli.New("myapp", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("echo", "echo something", cmd)) + + code := app.Run(context.Background(), []string{"echo", "--help"}) + assert.Equal(t, 0, code) + out := buf.String() + assert.Contains(t, out, "--message") + assert.Contains(t, out, "--count") + assert.Contains(t, out, "default: hello") +} + +func TestHelp_VersionFlag(t *testing.T) { + var buf bytes.Buffer + app := cli.New("myapp", "test", cli.WithOutput(&buf), cli.WithVersion("2.3.4")) + + code := app.Run(context.Background(), []string{"--version"}) + assert.Equal(t, 0, code) + assert.Contains(t, buf.String(), "2.3.4") +} + +func TestHelp_ShortAlias(t *testing.T) { + cmd := &echoCmd{} + var buf bytes.Buffer + app := cli.New("myapp", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("echo", "echo", cmd)) + + app.Run(context.Background(), []string{"echo", "--help"}) + out := buf.String() + assert.True(t, strings.Contains(out, "-m") || strings.Contains(out, "--message")) +} + +// --- edge cases --- + +func TestEdge_NoArgs(t *testing.T) { + var buf bytes.Buffer + app := cli.New("myapp", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("serve", "serve", &noopCmd{})) + + code := app.Run(context.Background(), []string{}) + assert.Equal(t, 0, code) + assert.Contains(t, buf.String(), "serve") +} + +func TestEdge_ArgsValidation_NoArgs(t *testing.T) { + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", &noopCmd{}, cli.WithArgs(cli.NoArgs))) + + code := app.Run(context.Background(), []string{"run", "extra"}) + assert.Equal(t, 1, code) +} + +func TestEdge_ArgsValidation_ExactArgs(t *testing.T) { + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", &noopCmd{}, cli.WithArgs(cli.ExactArgs(2)))) + + code := app.Run(context.Background(), []string{"run", "a", "b"}) + assert.Equal(t, 0, code) + + code = app.Run(context.Background(), []string{"run", "a"}) + assert.Equal(t, 1, code) +} + +func TestEdge_IsSet(t *testing.T) { + setCmd := &isSetCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", setCmd)) + + app.Run(context.Background(), []string{"run", "--name", "alice"}) + assert.True(t, setCmd.nameWasSet) +} + +func TestEdge_IsSet_NotSet(t *testing.T) { + setCmd := &isSetCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", setCmd)) + + app.Run(context.Background(), []string{"run"}) + assert.False(t, setCmd.nameWasSet) +} + +// --- config file loading tests --- + +type dbSection struct { + Host string `json:"host"` + Port int `json:"port"` +} + +type configHandler struct { + ConfigFile string `cli:"config,c" usage:"config file"` + DB dbSection ` config:"database"` +} + +func (c *configHandler) Run(_ context.Context, _ []string) error { return nil } + +func TestConfig_BasicLoad(t *testing.T) { + handler := &configHandler{} + f := writeConfigFile(t, `{ + "database": { + "host": "localhost", + "port": 5432 + } + }`) + + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithConfigFlag("config")) + app.AddCommand(cli.Command("run", "run", handler)) + + code := app.Run(context.Background(), []string{"run", "--config", f}) + assert.Equal(t, 0, code) + assert.Equal(t, "localhost", handler.DB.Host) + assert.Equal(t, 5432, handler.DB.Port) +} + +func TestConfig_MissingFileSilent(t *testing.T) { + handler := &configHandler{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithConfigFlag("config")) + app.AddCommand(cli.Command("run", "run", handler)) + + code := app.Run(context.Background(), []string{"run"}) + assert.Equal(t, 0, code) +} + +type overrideableHandler struct { + ConfigFile string `cli:"config" usage:"config file"` + Host string `cli:"host" usage:"host" default:"flag-default" config:"host"` +} + +func (o *overrideableHandler) Run(_ context.Context, _ []string) error { return nil } + +func TestConfig_CLIFlagOverridesConfig(t *testing.T) { + handler := &overrideableHandler{} + f := writeConfigFile(t, `{"host": "from-config"}`) + + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithConfigFlag("config")) + app.AddCommand(cli.Command("run", "run", handler)) + + code := app.Run(context.Background(), []string{"run", "--config", f, "--host", "from-flag"}) + assert.Equal(t, 0, code) + assert.Equal(t, "from-flag", handler.Host) +} + +func TestConfig_ConfigLoadsWhenNotExplicit(t *testing.T) { + handler := &overrideableHandler{} + f := writeConfigFile(t, `{"host": "from-config"}`) + + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithConfigFlag("config")) + app.AddCommand(cli.Command("run", "run", handler)) + + // Config file set but --host not set, so config value should apply + code := app.Run(context.Background(), []string{"run", "--config", f}) + assert.Equal(t, 0, code) + assert.Equal(t, "from-config", handler.Host) +} + +type nestedVal struct { + Val string `json:"val"` +} +type nestedConfigHandler struct { + ConfigFile string `cli:"config" usage:"config file"` + Nested nestedVal ` config:"a.b"` +} + +func (n *nestedConfigHandler) Run(_ context.Context, _ []string) error { return nil } + +func TestConfig_NestedPath(t *testing.T) { + handler := &nestedConfigHandler{} + f := writeConfigFile(t, `{ + "a": { + "b": { + "val": "deep" + } + } + }`) + + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithConfigFlag("config")) + app.AddCommand(cli.Command("run", "run", handler)) + + code := app.Run(context.Background(), []string{"run", "--config", f}) + assert.Equal(t, 0, code) + assert.Equal(t, "deep", handler.Nested.Val) +} + +type rootSection struct { + Foo string `json:"foo"` + Bar int `json:"bar"` +} +type rootConfigHandler struct { + ConfigFile string `cli:"config" usage:"config file"` + All rootSection ` config:"."` +} + +func (r *rootConfigHandler) Run(_ context.Context, _ []string) error { return nil } + +func TestConfig_RootPath(t *testing.T) { + handler := &rootConfigHandler{} + f := writeConfigFile(t, `{"foo": "baz", "bar": 99}`) + + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithConfigFlag("config")) + app.AddCommand(cli.Command("run", "run", handler)) + + code := app.Run(context.Background(), []string{"run", "--config", f}) + assert.Equal(t, 0, code) + assert.Equal(t, "baz", handler.All.Foo) + assert.Equal(t, 99, handler.All.Bar) +} + +func TestMiddleware_CommandInfo(t *testing.T) { + var capturedInfo cli.CommandInfo + mw := func(ctx context.Context, info cli.CommandInfo, next func(context.Context) error) error { + capturedInfo = info + return next(ctx) + } + + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithMiddleware(mw)) + app.AddCommand(cli.Group("admin", "admin", + cli.Command("migrate", "migrate", &noopCmd{}), + )) + + code := app.Run(context.Background(), []string{"admin", "migrate"}) + assert.Equal(t, 0, code) + assert.Equal(t, "admin migrate", capturedInfo.Name) +} + +func TestConfig_HuJSON(t *testing.T) { + handler := &configHandler{} + // HuJSON allows C-style comments and trailing commas. + f := writeConfigFile(t, `{ + // database connection settings + "database": { + "host": "hujson-host", + "port": 5433, // trailing comma + } + }`) + + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithConfigFlag("config")) + app.AddCommand(cli.Command("run", "run", handler)) + + code := app.Run(context.Background(), []string{"run", "--config", f}) + assert.Equal(t, 0, code) + assert.Equal(t, "hujson-host", handler.DB.Host) + assert.Equal(t, 5433, handler.DB.Port) +} + +// --- WithGlobals tests --- + +type testGlobals struct { + Verbose bool `cli:"verbose,v" usage:"verbose"` + Config string `cli:"config,c" usage:"config file" default:"config.json"` +} + +type simpleServeCmd struct { + Addr string `cli:"addr" usage:"listen address" default:":8080"` + globals *testGlobals +} + +func (s *simpleServeCmd) Run(ctx context.Context, _ []string) error { + s.globals = cli.GlobalsFromContext[testGlobals](ctx) + return nil +} + +func TestGlobals_FlagsAvailable(t *testing.T) { + g := &testGlobals{} + cmd := &simpleServeCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithGlobals(g)) + app.AddCommand(cli.Command("serve", "serve", cmd)) + + code := app.Run(context.Background(), []string{"serve", "--verbose", "--addr", ":9090"}) + assert.Equal(t, 0, code) + assert.True(t, g.Verbose) + assert.Equal(t, ":9090", cmd.Addr) +} + +func TestGlobals_FromContext(t *testing.T) { + g := &testGlobals{} + cmd := &simpleServeCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithGlobals(g)) + app.AddCommand(cli.Command("serve", "serve", cmd)) + + code := app.Run(context.Background(), []string{"serve", "--verbose"}) + assert.Equal(t, 0, code) + require.NotNil(t, cmd.globals) + assert.True(t, cmd.globals.Verbose) + assert.Equal(t, "config.json", cmd.globals.Config) // default +} + +func TestGlobals_ConfigFlagOnGlobals(t *testing.T) { + type serveWithConfig struct { + DB dbSection `config:"database"` + } + handler := &struct { + serveWithConfig + }{} + handler.serveWithConfig = serveWithConfig{} + + // Use a handler that has config tags but no config flag — + // the config flag is on globals. + g := &testGlobals{} + f := writeConfigFile(t, `{"database": {"host": "global-host", "port": 3306}}`) + + configCmd := &configHandler{DB: dbSection{}} + var buf bytes.Buffer + app := cli.New("app", "test", + cli.WithOutput(&buf), + cli.WithGlobals(g), + cli.WithConfigFlag("config"), + ) + app.AddCommand(cli.Command("run", "run", configCmd)) + + code := app.Run(context.Background(), []string{"run", "--config", f}) + assert.Equal(t, 0, code) + assert.Equal(t, "global-host", configCmd.DB.Host) + assert.Equal(t, 3306, configCmd.DB.Port) +} + +// --- CVR lifecycle on globals --- + +type cvrGlobals struct { + Verbose bool `cli:"verbose,v" usage:"verbose"` + Config string `cli:"config,c" usage:"config" default:"config.json"` + DSN string ` config:"db.dsn"` + + // Set during lifecycle + Configured bool + Validated bool + Closed bool + DB string // simulates an initialized resource +} + +func (g *cvrGlobals) Configure() error { + g.Configured = true + if g.DSN != "" { + g.DB = "pool:" + g.DSN // simulate opening a connection + } + return nil +} + +func (g *cvrGlobals) Validate() error { + g.Validated = true + return nil +} + +func (g *cvrGlobals) Close() error { + g.Closed = true + g.DB = "" + return nil +} + +type cvrCmd struct { + globalsSnapshot *cvrGlobals +} + +func (c *cvrCmd) Run(ctx context.Context, _ []string) error { + g := cli.GlobalsFromContext[cvrGlobals](ctx) + // Snapshot so test can check state during Run + c.globalsSnapshot = &cvrGlobals{ + Configured: g.Configured, + Validated: g.Validated, + Closed: g.Closed, + DB: g.DB, + } + return nil +} + +func TestGlobals_CVR_Lifecycle(t *testing.T) { + g := &cvrGlobals{} + cmd := &cvrCmd{} + f := writeConfigFile(t, `{"db": {"dsn": "postgres://localhost/test"}}`) + + var buf bytes.Buffer + app := cli.New("app", "test", + cli.WithOutput(&buf), + cli.WithGlobals(g), + cli.WithConfigFlag("config"), + ) + app.AddCommand(cli.Command("run", "run", cmd)) + + code := app.Run(context.Background(), []string{"run", "--config", f}) + assert.Equal(t, 0, code) + + // During Run: configured and validated, not yet closed + require.NotNil(t, cmd.globalsSnapshot) + assert.True(t, cmd.globalsSnapshot.Configured) + assert.True(t, cmd.globalsSnapshot.Validated) + assert.False(t, cmd.globalsSnapshot.Closed) + assert.Equal(t, "pool:postgres://localhost/test", cmd.globalsSnapshot.DB) + + // After Run: closed + assert.True(t, g.Closed) + assert.Equal(t, "", g.DB) // resource cleaned up +} + +func TestGlobals_CVR_ConfigureError(t *testing.T) { + // Configure runs before Validate — already covered by + // TestGlobals_CVR_Lifecycle ordering assertions. +} + +type cvrHandlerCmd struct { + Name string `cli:"name" usage:"name"` + configured bool + validated bool +} + +func (c *cvrHandlerCmd) Configure() error { + c.configured = true + return nil +} + +func (c *cvrHandlerCmd) Validate() error { + if !c.configured { + return errors.New("configure must run before validate") + } + c.validated = true + return nil +} + +func (c *cvrHandlerCmd) Run(_ context.Context, _ []string) error { + if !c.validated { + return errors.New("validate must run before run") + } + return nil +} + +func TestHandler_CVR_Lifecycle(t *testing.T) { + cmd := &cvrHandlerCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", cmd)) + + code := app.Run(context.Background(), []string{"run", "--name", "test"}) + assert.Equal(t, 0, code) + assert.True(t, cmd.configured) + assert.True(t, cmd.validated) +} + +func TestConfig_EnvVarReplacement(t *testing.T) { + handler := &overrideableHandler{} + t.Setenv("CLI_TEST_HOST", "env-replaced-host") + f := writeConfigFile(t, `{"host": "$CLI_TEST_HOST"}`) + + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithConfigFlag("config")) + app.AddCommand(cli.Command("run", "run", handler)) + + code := app.Run(context.Background(), []string{"run", "--config", f}) + assert.Equal(t, 0, code) + assert.Equal(t, "env-replaced-host", handler.Host) +} + +// --- Config precedence table-driven tests --- + +func TestConfig_Precedence(t *testing.T) { + tests := []struct { + name string + configJSON string + args []string + wantHost string + }{ + { + name: "flag overrides config", + configJSON: `{"host": "from-config"}`, + args: []string{"run", "--config", "", "--host", "from-flag"}, + wantHost: "from-flag", + }, + { + name: "config overrides default", + configJSON: `{"host": "from-config"}`, + args: []string{"run", "--config", ""}, + wantHost: "from-config", + }, + { + name: "default when no config and no flag", + configJSON: "", + args: []string{"run"}, + wantHost: "flag-default", + }, + { + name: "config key missing uses default", + configJSON: `{"other": "value"}`, + args: []string{"run", "--config", ""}, + wantHost: "flag-default", + }, + { + name: "empty config value overrides default", + configJSON: `{"host": ""}`, + args: []string{"run", "--config", ""}, + wantHost: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := &overrideableHandler{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithConfigFlag("config")) + app.AddCommand(cli.Command("run", "run", handler)) + + args := make([]string, len(tt.args)) + copy(args, tt.args) + + if tt.configJSON != "" { + f := writeConfigFile(t, tt.configJSON) + for i, a := range args { + if a == "" && i > 0 && args[i-1] == "--config" { + args[i] = f + } + } + } + + code := app.Run(context.Background(), args) + assert.Equal(t, 0, code, "output: %s", buf.String()) + assert.Equal(t, tt.wantHost, handler.Host) + }) + } +} + +// --- Config with globals: config tag on globals struct --- + +type globalsWithConfig struct { + Config string `cli:"config,c" usage:"config" default:"config.json"` + AppName string ` config:"app_name"` +} + +type plainCmd struct { + ran bool +} + +func (p *plainCmd) Run(_ context.Context, _ []string) error { + p.ran = true + return nil +} + +func TestConfig_GlobalsConfigTag(t *testing.T) { + g := &globalsWithConfig{} + cmd := &plainCmd{} + f := writeConfigFile(t, `{"app_name": "loaded-from-config"}`) + + var buf bytes.Buffer + app := cli.New("app", "test", + cli.WithOutput(&buf), + cli.WithGlobals(g), + cli.WithConfigFlag("config"), + ) + app.AddCommand(cli.Command("run", "run", cmd)) + + code := app.Run(context.Background(), []string{"run", "--config", f}) + assert.Equal(t, 0, code) + assert.True(t, cmd.ran) + assert.Equal(t, "loaded-from-config", g.AppName) +} + +// --- Flag parsing edge cases (table-driven) --- + +func TestFlagParsing_ShortFlagEdgeCases(t *testing.T) { + tests := []struct { + name string + args []string + wantVerbose bool + wantDebug bool + wantName string + }{ + { + name: "combined bool shorts", + args: []string{"-vd"}, + wantVerbose: true, + wantDebug: true, + }, + { + name: "combined bool + value: -vd is both bools", + args: []string{"-vd", "--name", "x"}, + wantVerbose: true, + wantDebug: true, + wantName: "x", + }, + { + name: "single short with value", + args: []string{"-v", "--name", "alice"}, + wantName: "alice", wantVerbose: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &boolCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", cmd)) + + code := app.Run(context.Background(), append([]string{"run"}, tt.args...)) + assert.Equal(t, 0, code, "output: %s", buf.String()) + assert.Equal(t, tt.wantVerbose, cmd.Verbose) + assert.Equal(t, tt.wantDebug, cmd.Debug) + assert.Equal(t, tt.wantName, cmd.Name) + }) + } +} + +// --- Type coverage for makeFlagDef --- + +type moreTypesCmd struct { + PStr *string `cli:"pstr" usage:"ptr string"` + PInt *int `cli:"pint" usage:"ptr int"` + PDur *time.Duration `cli:"pdur" usage:"ptr duration"` + PFlt *float64 `cli:"pflt" usage:"ptr float"` + PU64 *uint64 `cli:"pu64" usage:"ptr uint64"` + PBol *bool `cli:"pbol" usage:"ptr bool"` +} + +func (m *moreTypesCmd) Run(_ context.Context, _ []string) error { return nil } + +func TestFlagParsing_PointerTypes(t *testing.T) { + tests := []struct { + name string + args []string + check func(t *testing.T, cmd *moreTypesCmd) + }{ + { + name: "ptr string set", + args: []string{"--pstr", "hello"}, + check: func(t *testing.T, cmd *moreTypesCmd) { + require.NotNil(t, cmd.PStr) + assert.Equal(t, "hello", *cmd.PStr) + }, + }, + { + name: "ptr int set", + args: []string{"--pint", "42"}, + check: func(t *testing.T, cmd *moreTypesCmd) { + require.NotNil(t, cmd.PInt) + assert.Equal(t, 42, *cmd.PInt) + }, + }, + { + name: "ptr duration set", + args: []string{"--pdur", "5s"}, + check: func(t *testing.T, cmd *moreTypesCmd) { + require.NotNil(t, cmd.PDur) + assert.Equal(t, 5*time.Second, *cmd.PDur) + }, + }, + { + name: "ptr float set", + args: []string{"--pflt", "3.14"}, + check: func(t *testing.T, cmd *moreTypesCmd) { + require.NotNil(t, cmd.PFlt) + assert.InDelta(t, 3.14, *cmd.PFlt, 0.001) + }, + }, + { + name: "ptr uint64 set", + args: []string{"--pu64", "99"}, + check: func(t *testing.T, cmd *moreTypesCmd) { + require.NotNil(t, cmd.PU64) + assert.Equal(t, uint64(99), *cmd.PU64) + }, + }, + { + name: "ptr bool set", + args: []string{"--pbol"}, + check: func(t *testing.T, cmd *moreTypesCmd) { + require.NotNil(t, cmd.PBol) + assert.True(t, *cmd.PBol) + }, + }, + { + name: "unset ptrs remain nil", + args: []string{}, + check: func(t *testing.T, cmd *moreTypesCmd) { + assert.Nil(t, cmd.PStr) + assert.Nil(t, cmd.PInt) + assert.Nil(t, cmd.PDur) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &moreTypesCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", cmd)) + + code := app.Run(context.Background(), append([]string{"run"}, tt.args...)) + assert.Equal(t, 0, code, "output: %s", buf.String()) + tt.check(t, cmd) + }) + } +} + +// --- DumpConfig, DefaultConfig, ExitError --- + +func TestDumpConfig(t *testing.T) { + cmd := &echoCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("echo", "echo", cmd)) + + app.Run(context.Background(), []string{"echo", "--message", "hi"}) + m := cli.DumpConfig(cmd) + require.NotNil(t, m) + assert.Equal(t, "hi", m["message"]) + assert.Equal(t, 1, m["count"]) // default +} + +func TestDefaultConfig(t *testing.T) { + data := []byte(`{"key": "value"}`) + var buf bytes.Buffer + app := cli.New("app", "test", + cli.WithOutput(&buf), + cli.WithDefaultConfig(data), + ) + + code := app.Run(context.Background(), []string{"defcon"}) + assert.Equal(t, 0, code) + assert.Equal(t, string(data), buf.String()) +} + +func TestDefaultConfig_CustomCommand(t *testing.T) { + data := []byte(`{"x": 1}`) + var buf bytes.Buffer + app := cli.New("app", "test", + cli.WithOutput(&buf), + cli.WithDefaultConfig(data), + cli.WithDefaultConfigCommand("dump"), + ) + + code := app.Run(context.Background(), []string{"dump"}) + assert.Equal(t, 0, code) + assert.Equal(t, string(data), buf.String()) +} + +func TestDefaultConfig_NotRegistered(t *testing.T) { + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + + // Without WithDefaultConfig, "defcon" is not a recognized command. + code := app.Run(context.Background(), []string{"defcon"}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "unknown command") +} + +func TestExitError(t *testing.T) { + e := &cli.ExitError{Code: 3, Err: errors.New("boom")} + assert.Equal(t, "boom", e.Error()) + assert.Equal(t, "boom", e.Unwrap().Error()) + + e2 := &cli.ExitError{Code: 5} + assert.Contains(t, e2.Error(), "exit code 5") + assert.Nil(t, e2.Unwrap()) +} + +// --- Config file error paths --- + +func TestConfig_ExplicitMissingFileErrors(t *testing.T) { + handler := &configHandler{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithConfigFlag("config")) + app.AddCommand(cli.Command("run", "run", handler)) + + // Explicitly passing a non-existent file should error + code := app.Run(context.Background(), []string{"run", "--config", "/nonexistent/config.json"}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "reading config file") +} + +func TestConfig_InvalidJSON(t *testing.T) { + handler := &configHandler{} + f := writeConfigFile(t, `{not valid json`) + + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithConfigFlag("config")) + app.AddCommand(cli.Command("run", "run", handler)) + + code := app.Run(context.Background(), []string{"run", "--config", f}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "parsing config file") +} + +// --- MinArgs coverage --- + +func TestEdge_ArgsValidation_MinArgs(t *testing.T) { + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", &noopCmd{}, cli.WithArgs(cli.MinArgs(2)))) + + code := app.Run(context.Background(), []string{"run", "a", "b"}) + assert.Equal(t, 0, code) + + code = app.Run(context.Background(), []string{"run", "a"}) + assert.Equal(t, 1, code) +} + +// --- Handler Configure error --- + +type failConfigureCmd struct{} + +func (f *failConfigureCmd) Configure() error { return errors.New("configure failed") } +func (f *failConfigureCmd) Run(_ context.Context, _ []string) error { return nil } + +func TestHandler_ConfigureError(t *testing.T) { + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", &failConfigureCmd{})) + + code := app.Run(context.Background(), []string{"run"}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "configure") +} + +// --- Globals Configure/Validate errors --- + +type failGlobalsConfigure struct { + Config string `cli:"config,c" default:"config.json"` +} + +func (f *failGlobalsConfigure) Configure() error { + return errors.New("globals configure boom") +} + +func TestGlobals_ConfigureError(t *testing.T) { + g := &failGlobalsConfigure{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithGlobals(g)) + app.AddCommand(cli.Command("run", "run", &noopCmd{})) + + code := app.Run(context.Background(), []string{"run"}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "globals configure") +} + +type failGlobalsValidate struct { + Config string `cli:"config,c" default:"config.json"` +} + +func (f *failGlobalsValidate) Validate() error { + return errors.New("globals validate boom") +} + +func TestGlobals_ValidateError(t *testing.T) { + g := &failGlobalsValidate{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf), cli.WithGlobals(g)) + app.AddCommand(cli.Command("run", "run", &noopCmd{})) + + code := app.Run(context.Background(), []string{"run"}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "globals validate") +} + +// --- Long flag edge cases --- + +func TestFlagParsing_LongBoolWithEquals(t *testing.T) { + cmd := &boolCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("run", "run", cmd)) + + code := app.Run(context.Background(), []string{"run", "--verbose=true"}) + assert.Equal(t, 0, code) + assert.True(t, cmd.Verbose) +} + +func TestFlagParsing_LongFlagMissingValue(t *testing.T) { + cmd := &echoCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("echo", "echo", cmd)) + + code := app.Run(context.Background(), []string{"echo", "--message"}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "requires a value") +} + +func TestFlagParsing_ShortFlagMissingValue(t *testing.T) { + cmd := &echoCmd{} + var buf bytes.Buffer + app := cli.New("app", "test", cli.WithOutput(&buf)) + app.AddCommand(cli.Command("echo", "echo", cmd)) + + code := app.Run(context.Background(), []string{"echo", "-m"}) + assert.Equal(t, 1, code) + assert.Contains(t, buf.String(), "requires a value") +} diff --git a/cli/complete.go b/cli/complete.go new file mode 100644 index 0000000..de1c9dd --- /dev/null +++ b/cli/complete.go @@ -0,0 +1,261 @@ +package cli + +import ( + "context" + "fmt" + "io" + "strings" +) + +// Completer is optionally implemented by command handlers to provide +// custom completions for positional arguments. +type Completer interface { + Complete(ctx context.Context, args []string) []Completion +} + +// Completion represents a single shell completion suggestion. +type Completion struct { + Value string + Description string +} + +// handleCompletion checks if args[0] is "completion" or "__complete" and +// handles them directly. Returns true if handled, false otherwise. +func (a *App) handleCompletion(args []string) (int, bool) { + if len(args) == 0 { + return 0, false + } + switch args[0] { + case "completion": + return a.handleCompletionScript(args[1:]), true + case "__complete": + a.handleCompleteRequest(args[1:]) + return 0, true + } + return 0, false +} + +func (a *App) handleCompletionScript(args []string) int { + if len(args) != 1 { + fmt.Fprintf( + a.output, + "Usage: %s completion \n", + a.name, + ) + return 1 + } + // Completion scripts and __complete output go to stdout, not + // a.output (which defaults to stderr). The shell reads stdout. + switch args[0] { + case "bash": + writeBashCompletion(a.stdout, a.name) + case "zsh": + writeZshCompletion(a.stdout, a.name) + case "fish": + writeFishCompletion(a.stdout, a.name) + default: + fmt.Fprintf( + a.output, + "unsupported shell %q, expected bash, zsh, or fish\n", + args[0], + ) + return 1 + } + return 0 +} + +func (a *App) handleCompleteRequest(args []string) { + completions := a.computeCompletions(context.Background(), args) + for _, c := range completions { + if c.Description != "" { + fmt.Fprintf(a.stdout, "%s\t%s\n", c.Value, c.Description) + } else { + fmt.Fprintf(a.stdout, "%s\n", c.Value) + } + } +} + +func (a *App) computeCompletions( + ctx context.Context, args []string, +) []Completion { + // Walk the command tree to find the deepest matching node. + children := a.children + var currentNode Node + consumed := 0 + + for i := 0; i < len(args); i++ { + arg := args[i] + if strings.HasPrefix(arg, "-") { + break + } + found := false + for _, child := range children { + if child.nodeName() == arg { + currentNode = child + consumed = i + 1 + switch n := child.(type) { + case *commandNode: + children = n.children + case *groupNode: + children = n.children + } + found = true + break + } + } + if !found { + break + } + } + + remaining := args[consumed:] + + // Determine what the user is currently typing (the last token). + var current string + if len(remaining) > 0 { + current = remaining[len(remaining)-1] + } + + // If the current word starts with -, complete flags. + if strings.HasPrefix(current, "-") { + return a.completeFlags(currentNode, current) + } + + // Otherwise complete subcommands, or positional args via Completer. + var completions []Completion + + // Subcommand completions from the current level's children. + for _, child := range children { + name := child.nodeName() + if strings.HasPrefix(name, current) { + completions = append(completions, Completion{ + Value: name, + Description: child.nodeDesc(), + }) + } + } + + // If the current node is a command with a Completer handler, + // include its completions for positional args. + if cn, ok := currentNode.(*commandNode); ok { + if comp, ok := cn.handler.(Completer); ok { + // Pass remaining args (excluding the partial word being + // completed) as context. + var priorArgs []string + for _, r := range remaining { + if !strings.HasPrefix(r, "-") && r != current { + priorArgs = append(priorArgs, r) + } + } + custom := comp.Complete(ctx, priorArgs) + for _, c := range custom { + if strings.HasPrefix(c.Value, current) { + completions = append(completions, c) + } + } + } + } + + return completions +} + +func (a *App) completeFlags( + node Node, current string, +) []Completion { + var defs []*flagDef + + if cn, ok := node.(*commandNode); ok { + fs, _, err := buildFlagSet(cn.handler) + if err == nil { + if a.globals != nil { + if _, gerr := addGlobalsToFlagSet(fs, a.globals); gerr != nil { + return nil + } + } + defs = fs.defs + } + } + + // Always include --help. + defs = append(defs, &flagDef{ + long: "help", + short: "h", + usage: "show help", + }) + + // If globals are set but no specific command node, still offer + // global flags. + if node == nil && a.globals != nil { + fs := newFlagSet() + if _, err := addGlobalsToFlagSet(fs, a.globals); err == nil { + defs = append(defs, fs.defs...) + } + } + + prefix := strings.TrimLeft(current, "-") + var completions []Completion + seen := map[string]bool{} + for _, d := range defs { + flag := "--" + d.long + if seen[flag] { + continue + } + seen[flag] = true + if strings.HasPrefix(d.long, prefix) { + completions = append(completions, Completion{ + Value: flag, + Description: d.usage, + }) + } + } + return completions +} + +// writeBashCompletion outputs a bash completion script for the given app. +func writeBashCompletion(w io.Writer, appName string) { + fmt.Fprintf(w, `_%[1]s_completions() { + local cur="${COMP_WORDS[COMP_CWORD]}" + local IFS=$'\n' + local args=("${COMP_WORDS[@]:1:$COMP_CWORD}") + local completions + completions=$(%[1]s __complete "${args[@]}" 2>/dev/null) + COMPREPLY=() + while IFS=$'\t' read -r val desc; do + COMPREPLY+=("$val") + done <<< "$completions" +} +complete -F _%[1]s_completions %[1]s +`, appName) +} + +// writeZshCompletion outputs a zsh completion script for the given app. +func writeZshCompletion(w io.Writer, appName string) { + fmt.Fprintf(w, `#compdef %[1]s + +_%[1]s() { + local -a completions + local IFS=$'\n' + local args=("${words[@]:1:$CURRENT-1}") + completions=($(${words[1]} __complete "${args[@]}" 2>/dev/null)) + local -a descs + for line in "${completions[@]}"; do + if [[ "$line" == *$'\t'* ]]; then + local val="${line%%%%$'\t'*}" + local desc="${line#*$'\t'}" + descs+=("${val}:${desc}") + else + descs+=("${line}") + fi + done + _describe 'completions' descs +} + +compdef _%[1]s %[1]s +`, appName) +} + +// writeFishCompletion outputs a fish completion script for the given app. +func writeFishCompletion(w io.Writer, appName string) { + fmt.Fprintf(w, `complete -c %[1]s -f -a '(%[1]s __complete (commandline -cop) 2>/dev/null | string replace -r "\\t.*" "")' +`, appName) +} diff --git a/cli/complete_test.go b/cli/complete_test.go new file mode 100644 index 0000000..2c1138a --- /dev/null +++ b/cli/complete_test.go @@ -0,0 +1,230 @@ +package cli_test + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/runreveal/lib/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- completion test helpers --- + +type serveHandler struct { + Addr string `cli:"addr,a" usage:"listen address" default:":8080"` + Verbose bool `cli:"verbose,v" usage:"be verbose"` +} + +func (s *serveHandler) Run(_ context.Context, _ []string) error { + return nil +} + +type migrateHandler struct { + DryRun bool `cli:"dry-run" usage:"print migrations without running"` +} + +func (m *migrateHandler) Run(_ context.Context, _ []string) error { + return nil +} + +type completingHandler struct { + Name string `cli:"name" usage:"resource name"` +} + +func (c *completingHandler) Run(_ context.Context, _ []string) error { + return nil +} + +func (c *completingHandler) Complete( + _ context.Context, _ []string, +) []cli.Completion { + return []cli.Completion{ + {Value: "alpha", Description: "first resource"}, + {Value: "beta", Description: "second resource"}, + {Value: "gamma", Description: "third resource"}, + } +} + +type completeTestGlobals struct { + Config string `cli:"config,c" usage:"config file path"` + Debug bool `cli:"debug,d" usage:"enable debug"` +} + +func newTestApp(buf *bytes.Buffer) *cli.App { + globals := &completeTestGlobals{} + app := cli.New("testapp", "A test application", + cli.WithGlobals(globals), + cli.WithOutput(buf), + ) + app.AddCommand( + cli.Command("serve", "Start the server", &serveHandler{}), + cli.Command("get", "Get a resource", &completingHandler{}), + cli.Group("admin", "Administrative commands", + cli.Command( + "migrate", "Run database migrations", + &migrateHandler{}, + ), + ), + ) + return app +} + +func TestComplete(t *testing.T) { + tests := []struct { + name string + args []string + wantValues []string + wantAbsent []string + }{ + { + name: "top-level subcommands", + args: []string{"__complete", ""}, + wantValues: []string{"serve", "admin", "get"}, + }, + { + name: "partial subcommand match", + args: []string{"__complete", "se"}, + wantValues: []string{"serve"}, + wantAbsent: []string{"admin", "get"}, + }, + { + name: "flag completion for command", + args: []string{"__complete", "serve", "--"}, + wantValues: []string{"--addr", "--verbose", "--help"}, + }, + { + name: "flag completion with prefix", + args: []string{"__complete", "serve", "--a"}, + wantValues: []string{"--addr"}, + wantAbsent: []string{"--verbose"}, + }, + { + name: "global flags included in command flags", + args: []string{"__complete", "serve", "--"}, + wantValues: []string{"--config", "--debug"}, + }, + { + name: "nested command completion", + args: []string{"__complete", "admin", "mi"}, + wantValues: []string{"migrate"}, + }, + { + name: "nested command flag completion", + args: []string{"__complete", "admin", "migrate", "--"}, + wantValues: []string{"--dry-run", "--help"}, + }, + { + name: "completer interface for positional args", + args: []string{"__complete", "get", "al"}, + wantValues: []string{"alpha"}, + wantAbsent: []string{"beta", "gamma"}, + }, + { + name: "completer returns all matches on empty input", + args: []string{"__complete", "get", ""}, + wantValues: []string{"alpha", "beta", "gamma"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + app := newTestApp(&buf) + + code := app.Run(context.Background(), tt.args) + assert.Equal(t, 0, code) + + output := buf.String() + for _, want := range tt.wantValues { + assert.True( + t, + strings.Contains(output, want), + "expected %q in output:\n%s", want, output, + ) + } + for _, absent := range tt.wantAbsent { + assert.False( + t, + strings.Contains(output, absent), + "did not expect %q in output:\n%s", + absent, output, + ) + } + }) + } +} + +func TestCompletionScripts(t *testing.T) { + shells := []string{"bash", "zsh", "fish"} + + for _, shell := range shells { + t.Run(shell, func(t *testing.T) { + var buf bytes.Buffer + app := newTestApp(&buf) + + code := app.Run( + context.Background(), + []string{"completion", shell}, + ) + assert.Equal(t, 0, code) + + output := buf.String() + require.NotEmpty(t, output) + assert.Contains(t, output, "testapp") + }) + } +} + +func TestCompletionScriptInvalidShell(t *testing.T) { + var buf bytes.Buffer + app := newTestApp(&buf) + + code := app.Run( + context.Background(), + []string{"completion", "powershell"}, + ) + assert.Equal(t, 1, code) +} + +func TestCompletionScriptNoArg(t *testing.T) { + var buf bytes.Buffer + app := newTestApp(&buf) + + code := app.Run( + context.Background(), + []string{"completion"}, + ) + assert.Equal(t, 1, code) +} + +func TestCompletionCommandsNotInHelp(t *testing.T) { + var buf bytes.Buffer + app := newTestApp(&buf) + + // Running with no args should show help, which should not + // mention "completion" or "__complete". + app.Run(context.Background(), nil) + output := buf.String() + assert.NotContains(t, output, "completion") + assert.NotContains(t, output, "__complete") +} + +func TestCompleteOutputFormat(t *testing.T) { + var buf bytes.Buffer + app := newTestApp(&buf) + + app.Run(context.Background(), []string{"__complete", ""}) + output := buf.String() + + // Each line should have a tab separator for description. + lines := strings.Split(strings.TrimSpace(output), "\n") + for _, line := range lines { + assert.Contains( + t, line, "\t", + "expected tab-separated format in line: %s", line, + ) + } +} diff --git a/cli/config.go b/cli/config.go new file mode 100644 index 0000000..98df037 --- /dev/null +++ b/cli/config.go @@ -0,0 +1,153 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "reflect" + "regexp" + + "github.com/runreveal/lib/loader" + sgjson "github.com/segmentio/encoding/json" + "github.com/tidwall/gjson" +) + +// envVarRegex matches JSON string values that are exactly an env-var reference. +// This duplicates loader's regex because the cli module may pin a loader version +// that predates env var support, and because loader.LoadConfig into json.RawMessage +// may not apply replacement to opaque byte values. +var envVarRegex = regexp.MustCompile(`"\$([A-Za-z_][A-Za-z0-9_]*)"`) + +func replaceEnvInJSON(data []byte) []byte { + return envVarRegex.ReplaceAllFunc(data, func(match []byte) []byte { + name := string(match[2 : len(match)-1]) + val := os.Getenv(name) + encoded, err := json.Marshal(val) + if err != nil { + return match + } + return encoded + }) +} + +// resolveConfigJSON finds the config file path (from globals or handler), +// reads and processes it, and returns the JSON string. Returns "" if no +// config file is available. +func resolveConfigJSON( + handler Runnable, + globals any, + fs *FlagSet, + configFlagName string, + globalFields []fieldInfo, + handlerFields []fieldInfo, +) (string, error) { + if _, ok := fs.byLong[configFlagName]; !ok { + return "", nil + } + + // Find the config file path — check globals first, then handler. + var configPath string + if globals != nil { + rv := reflect.ValueOf(globals) + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + configPath = stringFieldForFlag(rv, globalFields, configFlagName) + } + if configPath == "" { + rv := reflect.ValueOf(handler) + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + configPath = stringFieldForFlag(rv, handlerFields, configFlagName) + } + if configPath == "" { + return "", nil + } + + explicit := fs.IsSet(configFlagName) + + data, err := os.ReadFile(configPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) && !explicit { + return "", nil + } + return "", fmt.Errorf("reading config file %q: %w", configPath, err) + } + + // Process via loader (hujson standardisation + env var replacement). + var raw json.RawMessage + if err := loader.LoadConfig(data, &raw); err != nil { + return "", fmt.Errorf("parsing config file %q: %w", configPath, err) + } + + return string(replaceEnvInJSON(raw)), nil +} + +// applyConfigTags applies config:"key" struct tags on a struct pointer. +func applyConfigTags(target any, fs *FlagSet, fields []fieldInfo, rawJSON string) error { + rv := reflect.ValueOf(target) + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + + for _, fi := range fields { + if fi.configKey == "" { + continue + } + // Don't override fields explicitly set by CLI flags + if fi.flagLong != "" && fs.IsSet(fi.flagLong) { + continue + } + + section := extractSection(rawJSON, fi.configKey) + if section == "" { + continue + } + + fieldVal := fieldByIndex(rv, fi.fieldIndex) + if err := unmarshalIntoField(fieldVal, section); err != nil { + return fmt.Errorf("config key %q: %w", fi.configKey, err) + } + } + return nil +} + +// extractSection extracts a JSON section by key path. +func extractSection(rawJSON, key string) string { + if key == "." { + return rawJSON + } + result := gjson.Get(rawJSON, key) + if !result.Exists() { + return "" + } + return result.Raw +} + +// stringFieldForFlag returns the current string value of the flag's struct field. +func stringFieldForFlag(rv reflect.Value, fields []fieldInfo, flagName string) string { + for _, fi := range fields { + if fi.flagLong != flagName { + continue + } + fieldVal := fieldByIndex(rv, fi.fieldIndex) + if fieldVal.Kind() == reflect.String { + return fieldVal.String() + } + if fieldVal.Kind() == reflect.Ptr && !fieldVal.IsNil() && fieldVal.Elem().Kind() == reflect.String { + return fieldVal.Elem().String() + } + } + return "" +} + +// unmarshalIntoField unmarshals a JSON string into the given reflect.Value. +func unmarshalIntoField(v reflect.Value, jsonStr string) error { + if !v.CanAddr() { + return fmt.Errorf("field is not addressable") + } + ptr := v.Addr().Interface() + return sgjson.Unmarshal([]byte(jsonStr), ptr) +} diff --git a/cli/example/config.json b/cli/example/config.json new file mode 100644 index 0000000..011871a --- /dev/null +++ b/cli/example/config.json @@ -0,0 +1,13 @@ +{ + // Example config for the CLI framework demo. + // HuJSON: comments and trailing commas are allowed. + + "sources": [ + {"type": "webhook", "path": "/hooks/github"}, + {"type": "syslog", "addr": ":514"}, + ], + + "cache": {"type": "memory", "max_size": 1000}, + + "server": {"addr": ":8080"}, +} diff --git a/cli/example/go.mod b/cli/example/go.mod new file mode 100644 index 0000000..c134493 --- /dev/null +++ b/cli/example/go.mod @@ -0,0 +1,22 @@ +module github.com/runreveal/lib/cli/example + +go 1.24 + +require ( + github.com/runreveal/lib/cli v0.0.0 + github.com/runreveal/lib/loader v0.0.0 +) + +require ( + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.3.6 // indirect + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + golang.org/x/sys v0.1.0 // indirect +) + +replace github.com/runreveal/lib/cli => ../ + +replace github.com/runreveal/lib/loader => ../../loader diff --git a/cli/example/go.sum b/cli/example/go.sum new file mode 100644 index 0000000..a6f0a81 --- /dev/null +++ b/cli/example/go.sum @@ -0,0 +1,29 @@ +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/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/runreveal/lib/await v0.0.0-20231128193746-50c2ad68891c h1:rZt9vvUOA1CstxYueKfaAU7zcLx5PyMSECtJGscP8wo= +github.com/runreveal/lib/await v0.0.0-20231128193746-50c2ad68891c/go.mod h1:qnRPgJExa5ziREWvAhSDMMTBSxBk9wX4D2xZBUBldmw= +github.com/runreveal/lib/loader v0.0.0-20231128193746-50c2ad68891c h1:F+kw4v9T9acZp7Ln29qlZvTRfaVMNzC6oO7To8OtATA= +github.com/runreveal/lib/loader v0.0.0-20231128193746-50c2ad68891c/go.mod h1:Wy+jC29YN5FuH7qC4guAb44D85Kb+zU28Xuerg0IuK0= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.3.6 h1:E6lVLyDPseWEulBmCmAKPanDd3jiyGDo5gMcugCRwZQ= +github.com/segmentio/encoding v0.3.6/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/example/main.go b/cli/example/main.go new file mode 100644 index 0000000..6dcbca7 --- /dev/null +++ b/cli/example/main.go @@ -0,0 +1,255 @@ +// Command example demonstrates the github.com/runreveal/lib/cli framework +// with github.com/runreveal/lib/loader for polymorphic config loading. +package main + +import ( + "context" + _ "embed" + "fmt" + "os" + "strings" + + "github.com/runreveal/lib/cli" + "github.com/runreveal/lib/loader" +) + +//go:embed config.json +var defaultConfig []byte + +// --------------------------------------------------------------------------- +// Source: an interface with multiple implementations loaded via loader +// --------------------------------------------------------------------------- + +type Source interface { + Name() string +} + +func init() { + loader.Register[Source]("webhook", func() loader.Builder[Source] { return &WebhookConfig{} }) + loader.Register[Source]("syslog", func() loader.Builder[Source] { return &SyslogConfig{} }) +} + +// WebhookConfig is the config for a webhook source. +type WebhookConfig struct { + Type string `json:"type"` + Path string `json:"path"` +} + +func (w *WebhookConfig) Configure() (Source, error) { + return &WebhookSource{path: w.Path}, nil +} + +func (w *WebhookConfig) Help() string { + return `Receives events via HTTP webhooks. Example: {"type": "webhook", "path": "/hooks/github"}` +} + +type WebhookSource struct{ path string } + +func (w *WebhookSource) Name() string { return "webhook:" + w.path } + +// SyslogConfig is the config for a syslog source. +type SyslogConfig struct { + Type string `json:"type"` + Addr string `json:"addr"` +} + +func (s *SyslogConfig) Configure() (Source, error) { + return &SyslogSource{addr: s.Addr}, nil +} + +func (s *SyslogConfig) Help() string { + return `Listens for syslog messages over UDP. Example: {"type": "syslog", "addr": ":514"}` +} + +type SyslogSource struct{ addr string } + +func (s *SyslogSource) Name() string { return "syslog:" + s.addr } + +// --------------------------------------------------------------------------- +// Cache: a single-value polymorphic config +// --------------------------------------------------------------------------- + +type Cache interface { + Name() string +} + +func init() { + loader.Register[Cache]("memory", func() loader.Builder[Cache] { return &MemoryCacheConfig{} }) +} + +type MemoryCacheConfig struct { + Type string `json:"type"` + MaxSize int `json:"max_size"` +} + +func (m *MemoryCacheConfig) Configure() (Cache, error) { + return &MemoryCache{maxSize: m.MaxSize}, nil +} + +func (m *MemoryCacheConfig) Help() string { + return `In-memory LRU cache. Example: {"type": "memory", "max_size": 1000}` +} + +type MemoryCache struct{ maxSize int } + +func (m *MemoryCache) Name() string { return fmt.Sprintf("memory(max=%d)", m.maxSize) } + +// --------------------------------------------------------------------------- +// ServerConfig: plain struct loaded from config file +// --------------------------------------------------------------------------- + +type ServerConfig struct { + Addr string `json:"addr"` +} + +// --------------------------------------------------------------------------- +// Globals: shared flags + config fields loaded directly from config file +// --------------------------------------------------------------------------- + +// Globals holds CLI flags, config-file-driven fields, and initialized +// resources. Config fields use config:"key" tags to pull their section +// from the config file — no wrapper struct needed. +type Globals struct { + // CLI flags + Verbose bool `cli:"verbose,v" usage:"enable verbose output"` + Config string `cli:"config,c" usage:"config file path" default:"config.json"` + + // Config file sections — each field maps to a top-level key + Sources []loader.Loader[Source] `config:"sources"` + Cache loader.Loader[Cache] `config:"cache"` + Server ServerConfig `config:"server"` + + // Initialized in Configure(), used by commands via GlobalsFromContext. + // These are runtime state, not config — but they live here because + // Globals is the natural singleton for the process. + sources []Source + cache Cache +} + +func (g *Globals) Configure() error { + for _, src := range g.Sources { + s, err := src.Configure() + if err != nil { + return fmt.Errorf("configuring source: %w", err) + } + g.sources = append(g.sources, s) + } + if g.Cache.Builder != nil { + c, err := g.Cache.Configure() + if err != nil { + return fmt.Errorf("configuring cache: %w", err) + } + g.cache = c + } + return nil +} + +func (g *Globals) Validate() error { + if len(g.sources) == 0 { + return fmt.Errorf("at least one source is required") + } + return nil +} + +// ExtraHelp implements cli.HelpExtra. It uses loader.List and +// loader.Describe to show available types and their config fields, +// bridging the cli and loader packages without either knowing +// about the other. +func (g *Globals) ExtraHelp() string { + var b strings.Builder + b.WriteString("\nAvailable source types:\n") + describeLoaderTypes[Source](&b) + b.WriteString("\nAvailable cache types:\n") + describeLoaderTypes[Cache](&b) + return b.String() +} + +// describeLoaderTypes lists registered loader types for T. If a type's +// builder implements loader.Helper, its Help() output is shown. Otherwise +// just the type name is listed. +func describeLoaderTypes[T any](b *strings.Builder) { + for _, name := range loader.List[T]() { + builder, ok := loader.Describe[T](name) + if !ok { + fmt.Fprintf(b, " %s\n", name) + continue + } + if h, ok := builder.(loader.Helper); ok { + fmt.Fprintf(b, " %s — %s\n", name, h.Help()) + } else { + fmt.Fprintf(b, " %s\n", name) + } + } +} + +// --------------------------------------------------------------------------- +// serve: uses initialized resources from Globals +// --------------------------------------------------------------------------- + +type ServeCmd struct { + Addr string `cli:"addr,a" usage:"listen address"` +} + +func (s *ServeCmd) Run(ctx context.Context, args []string) error { + g := cli.GlobalsFromContext[Globals](ctx) + if g == nil { + return fmt.Errorf("globals not available") + } + + addr := s.Addr + if addr == "" { + addr = g.Server.Addr + } + + fmt.Printf("server addr: %s\n", addr) + fmt.Printf("sources:\n") + for _, src := range g.sources { + fmt.Printf(" - %s\n", src.Name()) + } + if g.cache != nil { + fmt.Printf("cache: %s\n", g.cache.Name()) + } + return nil +} + +// --------------------------------------------------------------------------- +// list-sources: shows configured sources +// --------------------------------------------------------------------------- + +type ListSourcesCmd struct{} + +func (l *ListSourcesCmd) Run(ctx context.Context, args []string) error { + g := cli.GlobalsFromContext[Globals](ctx) + if g == nil { + return fmt.Errorf("globals not available") + } + + for _, src := range g.sources { + fmt.Println(src.Name()) + } + return nil +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +func main() { + globals := &Globals{} + + app := cli.New("example", "Example CLI demonstrating cli + loader", + cli.WithVersion("1.0.0"), + cli.WithGlobals(globals), + cli.WithConfigFlag("config"), + cli.WithDefaultConfig(defaultConfig), + ) + + app.AddCommand( + cli.Command("serve", "Start the HTTP server", &ServeCmd{}), + cli.Command("list-sources", "List configured sources", &ListSourcesCmd{}, + cli.WithArgs(cli.NoArgs), + ), + ) + + os.Exit(app.Run(context.Background(), os.Args[1:])) +} diff --git a/cli/flags.go b/cli/flags.go new file mode 100644 index 0000000..a9ac7d0 --- /dev/null +++ b/cli/flags.go @@ -0,0 +1,388 @@ +package cli + +import ( + "encoding" + "fmt" + "strconv" + "strings" + "time" +) + +// flagDef holds the definition of a single flag. +type flagDef struct { + long string // long name (without --) + short string // short alias (single char, without -) + usage string + defVal string + typeName string // for help display + setValue func(s string) error + isBool bool // true for boolean flags (value is optional) +} + +// FlagSet is a parsed set of flag definitions. +type FlagSet struct { + defs []*flagDef + byLong map[string]*flagDef + byShort map[string]*flagDef + explicit map[string]bool // flags explicitly set +} + +func newFlagSet() *FlagSet { + return &FlagSet{ + byLong: make(map[string]*flagDef), + byShort: make(map[string]*flagDef), + explicit: make(map[string]bool), + } +} + +func (fs *FlagSet) add(def *flagDef) error { + if def.long != "" { + if _, exists := fs.byLong[def.long]; exists { + return fmt.Errorf("duplicate flag --%s", def.long) + } + fs.byLong[def.long] = def + } + if def.short != "" { + if _, exists := fs.byShort[def.short]; exists { + return fmt.Errorf("duplicate short flag -%s", def.short) + } + fs.byShort[def.short] = def + } + fs.defs = append(fs.defs, def) + return nil +} + +// IsSet reports whether the named flag was explicitly set. +func (fs *FlagSet) IsSet(name string) bool { + return fs.explicit[name] +} + +// Parse parses args, sets flag values, and returns remaining positional args. +func (fs *FlagSet) Parse(args []string) ([]string, error) { + var positional []string + i := 0 + for i < len(args) { + arg := args[i] + if arg == "--" { + positional = append(positional, args[i+1:]...) + break + } + if !strings.HasPrefix(arg, "-") || arg == "-" { + positional = append(positional, arg) + i++ + continue + } + + var err error + var advance int + if strings.HasPrefix(arg, "--") { + advance, err = fs.parseLong(arg[2:], args[i+1:]) + } else { + advance, err = fs.parseShort(arg[1:], args[i+1:]) + } + if err != nil { + return nil, err + } + i += 1 + advance + } + return positional, nil +} + +func (fs *FlagSet) parseLong(raw string, remaining []string) (int, error) { + name, val, hasEq := strings.Cut(raw, "=") + def, ok := fs.byLong[name] + if !ok { + return 0, fmt.Errorf("unknown flag: --%s", name) + } + + if def.isBool { + // Bool flag: value is optional + if hasEq { + if err := def.setValue(val); err != nil { + return 0, fmt.Errorf("invalid value %q for --%s: %w", val, name, err) + } + } else { + if err := def.setValue("true"); err != nil { + return 0, err + } + } + fs.explicit[name] = true + return 0, nil + } + + if hasEq { + if err := def.setValue(val); err != nil { + return 0, fmt.Errorf("invalid value %q for --%s: %w", val, name, err) + } + fs.explicit[name] = true + return 0, nil + } + + if len(remaining) == 0 { + return 0, fmt.Errorf("flag --%s requires a value", name) + } + if err := def.setValue(remaining[0]); err != nil { + return 0, fmt.Errorf("invalid value %q for --%s: %w", remaining[0], name, err) + } + fs.explicit[name] = true + return 1, nil +} + +func (fs *FlagSet) parseShort(raw string, remaining []string) (int, error) { + // raw is everything after the leading - + // e.g. "abc" for -abc, "v" for -v, "f=val" for -f=val + + // Handle -f=value + if idx := strings.IndexByte(raw, '='); idx == 1 { + char := string(raw[0]) + val := raw[2:] + def, ok := fs.byShort[char] + if !ok { + return 0, fmt.Errorf("unknown flag: -%s", char) + } + if err := def.setValue(val); err != nil { + return 0, fmt.Errorf("invalid value %q for -%s: %w", val, char, err) + } + fs.explicit[def.long] = true + return 0, nil + } + + // Single short flag + if len(raw) == 1 { + char := raw + def, ok := fs.byShort[char] + if !ok { + return 0, fmt.Errorf("unknown flag: -%s", char) + } + if def.isBool { + if err := def.setValue("true"); err != nil { + return 0, err + } + fs.explicit[def.long] = true + return 0, nil + } + if len(remaining) == 0 { + return 0, fmt.Errorf("flag -%s requires a value", char) + } + if err := def.setValue(remaining[0]); err != nil { + return 0, fmt.Errorf("invalid value %q for -%s: %w", remaining[0], char, err) + } + fs.explicit[def.long] = true + return 1, nil + } + + // Multiple short bool flags combined: -abc + for j, ch := range raw { + char := string(ch) + def, ok := fs.byShort[char] + if !ok { + return 0, fmt.Errorf("unknown flag: -%s", char) + } + if !def.isBool { + // Non-bool flag: rest of the string is the value + val := raw[j+1:] + if val == "" { + if len(remaining) == 0 { + return 0, fmt.Errorf("flag -%s requires a value", char) + } + if err := def.setValue(remaining[0]); err != nil { + return 0, fmt.Errorf("invalid value %q for -%s: %w", remaining[0], char, err) + } + fs.explicit[def.long] = true + return 1, nil + } + if err := def.setValue(val); err != nil { + return 0, fmt.Errorf("invalid value %q for -%s: %w", val, char, err) + } + fs.explicit[def.long] = true + return 0, nil + } + if err := def.setValue("true"); err != nil { + return 0, err + } + fs.explicit[def.long] = true + } + return 0, nil +} + +// makeFlagDef creates a flagDef for a given field pointer. +// ptr must be a pointer to the field value. +func makeFlagDef(long, short, usage, defVal string, ptr any) (*flagDef, error) { + def := &flagDef{ + long: long, + short: short, + usage: usage, + defVal: defVal, + } + + switch p := ptr.(type) { + case *string: + def.typeName = "string" + def.setValue = func(s string) error { *p = s; return nil } + case **string: + def.typeName = "string" + def.setValue = func(s string) error { *p = &s; return nil } + case *bool: + def.typeName = "" + def.isBool = true + def.setValue = func(s string) error { + v, err := strconv.ParseBool(s) + if err != nil { + return err + } + *p = v + return nil + } + case **bool: + def.typeName = "" + def.isBool = true + def.setValue = func(s string) error { + v, err := strconv.ParseBool(s) + if err != nil { + return err + } + *p = &v + return nil + } + case *int: + def.typeName = "int" + def.setValue = func(s string) error { + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return err + } + *p = int(v) + return nil + } + case **int: + def.typeName = "int" + def.setValue = func(s string) error { + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return err + } + iv := int(v) + *p = &iv + return nil + } + case *int64: + def.typeName = "int" + def.setValue = func(s string) error { + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return err + } + *p = v + return nil + } + case **int64: + def.typeName = "int" + def.setValue = func(s string) error { + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return err + } + *p = &v + return nil + } + case *uint: + def.typeName = "uint" + def.setValue = func(s string) error { + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return err + } + *p = uint(v) + return nil + } + case **uint: + def.typeName = "uint" + def.setValue = func(s string) error { + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return err + } + uv := uint(v) + *p = &uv + return nil + } + case *uint64: + def.typeName = "uint" + def.setValue = func(s string) error { + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return err + } + *p = v + return nil + } + case **uint64: + def.typeName = "uint" + def.setValue = func(s string) error { + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return err + } + *p = &v + return nil + } + case *float64: + def.typeName = "float" + def.setValue = func(s string) error { + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return err + } + *p = v + return nil + } + case **float64: + def.typeName = "float" + def.setValue = func(s string) error { + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return err + } + *p = &v + return nil + } + case *time.Duration: + def.typeName = "duration" + def.setValue = func(s string) error { + v, err := time.ParseDuration(s) + if err != nil { + return err + } + *p = v + return nil + } + case **time.Duration: + def.typeName = "duration" + def.setValue = func(s string) error { + v, err := time.ParseDuration(s) + if err != nil { + return err + } + *p = &v + return nil + } + case *[]string: + def.typeName = "strings" + def.setValue = func(s string) error { + *p = append(*p, s) + return nil + } + default: + // Check for TextUnmarshaler + if tu, ok := ptr.(encoding.TextUnmarshaler); ok { + def.typeName = "string" + def.setValue = func(s string) error { + return tu.UnmarshalText([]byte(s)) + } + } else { + return nil, fmt.Errorf("unsupported flag type %T", ptr) + } + } + + return def, nil +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..ad4d6d0 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,21 @@ +module github.com/runreveal/lib/cli + +go 1.24 + +require ( + github.com/runreveal/lib/loader v0.0.0-20231128193746-50c2ad68891c + github.com/segmentio/encoding v0.3.6 + github.com/stretchr/testify v1.8.4 + github.com/tidwall/gjson v1.14.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + golang.org/x/sys v0.1.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..ec44c3f --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,29 @@ +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/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/runreveal/lib/loader v0.0.0-20231128193746-50c2ad68891c h1:F+kw4v9T9acZp7Ln29qlZvTRfaVMNzC6oO7To8OtATA= +github.com/runreveal/lib/loader v0.0.0-20231128193746-50c2ad68891c/go.mod h1:Wy+jC29YN5FuH7qC4guAb44D85Kb+zU28Xuerg0IuK0= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.3.6 h1:E6lVLyDPseWEulBmCmAKPanDd3jiyGDo5gMcugCRwZQ= +github.com/segmentio/encoding v0.3.6/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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= diff --git a/cli/help.go b/cli/help.go new file mode 100644 index 0000000..fe6d1b3 --- /dev/null +++ b/cli/help.go @@ -0,0 +1,171 @@ +package cli + +import ( + "fmt" + "io" + "strings" +) + +func printAppHelp(w io.Writer, appName, desc string, children []Node, version, defconCmd string) { + if desc != "" { + fmt.Fprintf(w, "%s - %s\n\n", appName, desc) + } else { + fmt.Fprintf(w, "%s\n\n", appName) + } + + fmt.Fprintf(w, "Usage:\n") + if len(children) > 0 { + fmt.Fprintf(w, " %s [flags]\n\n", appName) + } else { + fmt.Fprintf(w, " %s [flags]\n\n", appName) + } + + if len(children) > 0 { + fmt.Fprintf(w, "Commands:\n") + maxLen := maxNodeNameLen(children) + for _, child := range children { + fmt.Fprintf(w, " %-*s %s\n", maxLen, child.nodeName(), child.nodeDesc()) + } + fmt.Fprintln(w) + } + + fmt.Fprintf(w, "Flags:\n") + fmt.Fprintf(w, " -h, --help show help\n") + if version != "" { + fmt.Fprintf(w, " --version show version\n") + } + + if len(children) > 0 { + fmt.Fprintf(w, "\nUse \"%s --help\" for more information.\n", appName) + } + if defconCmd != "" { + fmt.Fprintf(w, "Use \"%s %s\" to print the default configuration.\n", appName, defconCmd) + } +} + +func printGroupHelp(w io.Writer, appName, path, desc string, children []Node) { + if desc != "" { + fmt.Fprintf(w, "%s %s - %s\n\n", appName, path, desc) + } else { + fmt.Fprintf(w, "%s %s\n\n", appName, path) + } + + fmt.Fprintf(w, "Usage:\n") + fmt.Fprintf(w, " %s %s [flags]\n\n", appName, path) + + if len(children) > 0 { + fmt.Fprintf(w, "Commands:\n") + maxLen := maxNodeNameLen(children) + for _, child := range children { + fmt.Fprintf(w, " %-*s %s\n", maxLen, child.nodeName(), child.nodeDesc()) + } + fmt.Fprintln(w) + } + + fmt.Fprintf(w, "Flags:\n") + fmt.Fprintf(w, " -h, --help show help\n") + fmt.Fprintf(w, "\nUse \"%s %s --help\" for more information.\n", appName, path) +} + +func printCommandHelp(w io.Writer, appName, path, desc string, handler Runnable, children []Node, globals any) { + if desc != "" { + fmt.Fprintf(w, "%s %s - %s\n\n", appName, path, desc) + } else { + fmt.Fprintf(w, "%s %s\n\n", appName, path) + } + + fmt.Fprintf(w, "Usage:\n") + if len(children) > 0 { + fmt.Fprintf(w, " %s %s [command] [flags]\n\n", appName, path) + } else { + fmt.Fprintf(w, " %s %s [flags]\n\n", appName, path) + } + + if len(children) > 0 { + fmt.Fprintf(w, "Commands:\n") + maxLen := maxNodeNameLen(children) + for _, child := range children { + fmt.Fprintf(w, " %-*s %s\n", maxLen, child.nodeName(), child.nodeDesc()) + } + fmt.Fprintln(w) + } + + // Print flags from handler + globals + fs, _, err := buildFlagSet(handler) + if err == nil { + if globals != nil { + if _, gerr := addGlobalsToFlagSet(fs, globals); gerr != nil { + fmt.Fprintf(w, " (error loading global flags: %s)\n", gerr) + } + } + if len(fs.defs) > 0 { + fmt.Fprintf(w, "Flags:\n") + printFlagDefs(w, fs.defs) + fmt.Fprintln(w) + } else { + fmt.Fprintf(w, "Flags:\n") + } + } else { + fmt.Fprintf(w, "Flags:\n") + } + fmt.Fprintf(w, " -h, --help show help\n") + + // Append extra help from handler and/or globals if they implement HelpExtra. + if he, ok := handler.(HelpExtra); ok { + fmt.Fprint(w, he.ExtraHelp()) + } + if globals != nil { + if he, ok := globals.(HelpExtra); ok { + fmt.Fprint(w, he.ExtraHelp()) + } + } +} + +func printFlagDefs(w io.Writer, defs []*flagDef) { + // Calculate column widths + maxFlag := 0 + for _, d := range defs { + s := flagString(d) + if len(s) > maxFlag { + maxFlag = len(s) + } + } + + for _, d := range defs { + fs := flagString(d) + padding := strings.Repeat(" ", maxFlag-len(fs)) + if d.defVal != "" { + fmt.Fprintf(w, " %s%s %s (default: %s)\n", fs, padding, d.usage, d.defVal) + } else { + fmt.Fprintf(w, " %s%s %s\n", fs, padding, d.usage) + } + } +} + +func flagString(d *flagDef) string { + var sb strings.Builder + if d.short != "" { + sb.WriteString("-") + sb.WriteString(d.short) + sb.WriteString(", ") + } else { + sb.WriteString(" ") + } + sb.WriteString("--") + sb.WriteString(d.long) + if d.typeName != "" { + sb.WriteString(" ") + sb.WriteString(d.typeName) + } + return sb.String() +} + +func maxNodeNameLen(nodes []Node) int { + max := 0 + for _, n := range nodes { + if l := len(n.nodeName()); l > max { + max = l + } + } + return max +} diff --git a/cli/reflect.go b/cli/reflect.go new file mode 100644 index 0000000..871bea7 --- /dev/null +++ b/cli/reflect.go @@ -0,0 +1,198 @@ +package cli + +import ( + "fmt" + "reflect" + "strings" +) + +// fieldInfo holds parsed tag info for a struct field. +type fieldInfo struct { + flagLong string + flagShort string + configKey string + usage string + defVal string + fieldIndex []int // nested index path for embedded structs + fieldType reflect.Type +} + +// scanFields walks a struct type and extracts all field metadata. +// It handles embedded structs by recursively scanning them. +func scanFields(t reflect.Type) ([]fieldInfo, error) { + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil, fmt.Errorf("expected struct, got %s", t.Kind()) + } + return scanFieldsWithIndex(t, nil) +} + +func scanFieldsWithIndex(t reflect.Type, prefix []int) ([]fieldInfo, error) { + var fields []fieldInfo + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + // Copy prefix into a new slice before appending i to avoid + // aliasing the backing array across loop iterations. + idx := append(append([]int{}, prefix...), i) + + // Handle embedded structs (anonymous fields) + ft := f.Type + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + if f.Anonymous && ft.Kind() == reflect.Struct { + sub, err := scanFieldsWithIndex(ft, idx) + if err != nil { + return nil, err + } + fields = append(fields, sub...) + continue + } + + cliTag := f.Tag.Get("cli") + configTag := f.Tag.Get("config") + usage := f.Tag.Get("usage") + defVal := f.Tag.Get("default") + + // Skip if no relevant tags at all + if cliTag == "" && configTag == "" { + continue + } + + // cli:"-" means skip + if cliTag == "-" { + continue + } + + info := fieldInfo{ + configKey: configTag, + usage: usage, + defVal: defVal, + fieldIndex: idx, + fieldType: f.Type, + } + + if cliTag != "" { + parts := strings.SplitN(cliTag, ",", 2) + info.flagLong = parts[0] + if len(parts) == 2 { + info.flagShort = strings.TrimSpace(parts[1]) + } + } + + fields = append(fields, info) + } + return fields, nil +} + +// buildFlagSet constructs a FlagSet by reflecting on the handler's struct tags. +// It also returns the scanned fields so callers can reuse them (e.g. for +// applyDefaults and config loading) without re-scanning. +func buildFlagSet(handler Runnable) (*FlagSet, []fieldInfo, error) { + rv := reflect.ValueOf(handler) + if rv.Kind() != reflect.Ptr { + return nil, nil, fmt.Errorf("handler must be a pointer to a struct") + } + rv = rv.Elem() + if rv.Kind() != reflect.Struct { + return nil, nil, fmt.Errorf("handler must be a pointer to a struct") + } + + fields, err := scanFields(rv.Type()) + if err != nil { + return nil, nil, err + } + + fs := newFlagSet() + for _, fi := range fields { + if fi.flagLong == "" { + continue + } + + fieldVal := fieldByIndex(rv, fi.fieldIndex) + ptr := fieldVal.Addr().Interface() + + def, err := makeFlagDef(fi.flagLong, fi.flagShort, fi.usage, fi.defVal, ptr) + if err != nil { + return nil, nil, fmt.Errorf("field %v: %w", fi.fieldIndex, err) + } + + if err := fs.add(def); err != nil { + return nil, nil, err + } + } + + return fs, fields, nil +} + +// addGlobalsToFlagSet scans a globals struct pointer and adds its cli-tagged +// fields to the given FlagSet. Returns the scanned fields for default application. +func addGlobalsToFlagSet(fs *FlagSet, globals any) ([]fieldInfo, error) { + rv := reflect.ValueOf(globals) + if rv.Kind() != reflect.Ptr || rv.Elem().Kind() != reflect.Struct { + return nil, fmt.Errorf("globals must be a pointer to a struct") + } + rv = rv.Elem() + + fields, err := scanFields(rv.Type()) + if err != nil { + return nil, err + } + + for _, fi := range fields { + if fi.flagLong == "" { + continue + } + // Skip if handler already defines this flag (handler wins). + if _, exists := fs.byLong[fi.flagLong]; exists { + continue + } + + fieldVal := fieldByIndex(rv, fi.fieldIndex) + ptr := fieldVal.Addr().Interface() + + def, err := makeFlagDef(fi.flagLong, fi.flagShort, fi.usage, fi.defVal, ptr) + if err != nil { + return nil, fmt.Errorf("globals field %v: %w", fi.fieldIndex, err) + } + if err := fs.add(def); err != nil { + return nil, err + } + } + + return fields, nil +} + +// applyDefaults sets the default values using pre-scanned fields. +func applyDefaults(fs *FlagSet, fields []fieldInfo) error { + for _, fi := range fields { + if fi.defVal == "" || fi.flagLong == "" { + continue + } + def, ok := fs.byLong[fi.flagLong] + if !ok { + continue + } + if err := def.setValue(fi.defVal); err != nil { + return fmt.Errorf("applying default %q to --%s: %w", fi.defVal, fi.flagLong, err) + } + } + return nil +} + +// fieldByIndex retrieves a nested struct field by index path, initializing +// embedded pointer structs as needed. +func fieldByIndex(v reflect.Value, index []int) reflect.Value { + for _, i := range index { + if v.Kind() == reflect.Ptr { + if v.IsNil() { + v.Set(reflect.New(v.Type().Elem())) + } + v = v.Elem() + } + v = v.Field(i) + } + return v +} diff --git a/loader/loader.go b/loader/loader.go index 21e316a..fe550d4 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -6,6 +6,7 @@ import ( "os" "reflect" "regexp" + "sort" "sync" "github.com/segmentio/encoding/json" @@ -83,6 +84,60 @@ func Register[T any](name string, factory func() Builder[T]) { registryForType.m[name] = factory } +// List returns the sorted names of all registered implementations for type T. +func List[T any]() []string { + typ := new(T) + typStr := reflect.TypeOf(typ).String() + + registry.RLock() + defer registry.RUnlock() + + raw, ok := registry.m[typStr] + if !ok { + return nil + } + reg := raw.(*Registry[T]) + reg.RLock() + defer reg.RUnlock() + + names := make([]string, 0, len(reg.m)) + for name := range reg.m { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// Describe returns a zero-value Builder for the named type, useful for +// introspecting config fields (e.g. reflecting on JSON struct tags for +// help output). Returns nil, false if the name is not registered. +func Describe[T any](name string) (Builder[T], bool) { + typ := new(T) + typStr := reflect.TypeOf(typ).String() + + registry.RLock() + raw, ok := registry.m[typStr] + registry.RUnlock() + if !ok { + return nil, false + } + reg := raw.(*Registry[T]) + reg.RLock() + factory, ok := reg.m[name] + reg.RUnlock() + if !ok { + return nil, false + } + return factory(), true +} + +// Helper is optionally implemented by Builder types to provide a +// human-readable description of their configuration. Used by CLI +// tools to show available config options in help output. +type Helper interface { + Help() string +} + // Loader is a struct which can dyanmically unmarshal any type T type Loader[T any] struct { Builder[T]