Skip to content

Commit 65509a9

Browse files
committed
Add ConfigResolver for external config source support
Introduce ConfigResolver function type and ConfigProvider interface for loading flag values from external sources (JSON, YAML, TOML, etc.). Refactor flag pipeline into applyDefaults, applyConfig, applyEnv steps with priority: explicit flag > env > config > default > zero. Add config subpackage with FromMap, FromJSON (io.Reader), and Chain helpers.
1 parent 9d57a23 commit 65509a9

10 files changed

Lines changed: 713 additions & 40 deletions

File tree

cli.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type options struct {
2727
stderr io.Writer
2828
flagParser FlagParser
2929
helpRenderer HelpRenderer
30+
configResolver ConfigResolver
3031
suggest bool
3132
shortOptionHandling bool
3233
prefixMatching bool
@@ -78,6 +79,13 @@ func WithPrefixMatching(enabled bool) Option {
7879
return func(o *options) { o.prefixMatching = enabled }
7980
}
8081

82+
// WithConfigResolver sets a global config resolver for flag values.
83+
// Config values have lower priority than env vars and explicit CLI flags,
84+
// but higher priority than defaults: explicit flag > env > config > default > zero.
85+
func WithConfigResolver(r ConfigResolver) Option {
86+
return func(o *options) { o.configResolver = r }
87+
}
88+
8189
// Execute runs the command tree rooted at root with the given args and options.
8290
// It resolves subcommands, parses flags, runs lifecycle hooks, and executes
8391
// the target command.

config/config.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Package config provides [cli.ConfigResolver] implementations for common
2+
// configuration sources.
3+
//
4+
// A [cli.ConfigResolver] is a function that maps flag names to string values.
5+
// The cli framework handles type conversion, validation, required checks, and
6+
// enum enforcement — resolvers only return strings. This package ships
7+
// format-agnostic building blocks; any source that can produce a
8+
// map[string]string works out of the box via [FromMap].
9+
//
10+
// Priority chain: explicit CLI flag > env var > config > default > zero value.
11+
//
12+
// # Provided resolvers
13+
//
14+
// - [FromMap] — backed by a string map (useful for testing, in-memory config, or as the building block for custom formats)
15+
// - [FromJSON] — decodes a flat JSON object from an [io.Reader]
16+
// - [Chain] — tries multiple resolvers in order, returning the first match
17+
//
18+
// # Custom format adapters
19+
//
20+
// Because [FromMap] accepts any map[string]string, adding support for a new
21+
// configuration format is a matter of decoding into a flat map. The examples
22+
// below are complete, copy-paste-ready adapters.
23+
//
24+
// YAML (using gopkg.in/yaml.v3):
25+
//
26+
// func FromYAML(r io.Reader) (cli.ConfigResolver, error) {
27+
// var m map[string]string
28+
// if err := yaml.NewDecoder(r).Decode(&m); err != nil {
29+
// return nil, err
30+
// }
31+
// return config.FromMap(m), nil
32+
// }
33+
//
34+
// TOML (using github.com/BurntSushi/toml):
35+
//
36+
// func FromTOML(r io.Reader) (cli.ConfigResolver, error) {
37+
// var m map[string]string
38+
// if _, err := toml.NewDecoder(r).Decode(&m); err != nil {
39+
// return nil, err
40+
// }
41+
// return config.FromMap(m), nil
42+
// }
43+
//
44+
// HCL (using github.com/hashicorp/hcl/v2):
45+
//
46+
// func FromHCL(r io.Reader) (cli.ConfigResolver, error) {
47+
// data, err := io.ReadAll(r)
48+
// if err != nil { return nil, err }
49+
// var m map[string]string
50+
// if err := hcl.Unmarshal(data, &m); err != nil {
51+
// return nil, err
52+
// }
53+
// return config.FromMap(m), nil
54+
// }
55+
//
56+
// .env files (using github.com/joho/godotenv):
57+
//
58+
// func FromDotenv(r io.Reader) (cli.ConfigResolver, error) {
59+
// m, err := godotenv.Parse(r)
60+
// if err != nil { return nil, err }
61+
// return config.FromMap(m), nil
62+
// }
63+
//
64+
// # Direct resolver functions
65+
//
66+
// For sources that don't map cleanly to a flat file (remote stores,
67+
// structured configs, or computed values), implement [cli.ConfigResolver]
68+
// directly:
69+
//
70+
// // Consul KV adapter
71+
// func FromConsul(client *consul.Client, prefix string) cli.ConfigResolver {
72+
// return func(flagName string) (string, bool) {
73+
// pair, _, err := client.KV().Get(prefix+"/"+flagName, nil)
74+
// if err != nil || pair == nil { return "", false }
75+
// return string(pair.Value), true
76+
// }
77+
// }
78+
//
79+
// # Layered configuration
80+
//
81+
// Use [Chain] to try multiple sources in priority order. The first resolver
82+
// that finds a value wins:
83+
//
84+
// resolver := config.Chain(
85+
// localOverrides, // highest priority config source
86+
// projectConfig, // project-level config
87+
// globalConfig, // user-level defaults
88+
// )
89+
package config
90+
91+
import (
92+
"encoding/json"
93+
"io"
94+
95+
"github.com/bjaus/cli"
96+
)
97+
98+
// FromMap returns a [cli.ConfigResolver] backed by a string map.
99+
func FromMap(m map[string]string) cli.ConfigResolver {
100+
return func(flagName string) (string, bool) {
101+
v, ok := m[flagName]
102+
return v, ok
103+
}
104+
}
105+
106+
// FromJSON decodes a flat JSON object from r and returns a [cli.ConfigResolver].
107+
// The JSON must be a flat object with string values: {"port": "8080"}.
108+
func FromJSON(r io.Reader) (cli.ConfigResolver, error) {
109+
var m map[string]string
110+
if err := json.NewDecoder(r).Decode(&m); err != nil {
111+
return nil, err
112+
}
113+
return FromMap(m), nil
114+
}
115+
116+
// Chain returns a [cli.ConfigResolver] that tries each resolver in order,
117+
// returning the value from the first resolver that reports found.
118+
func Chain(resolvers ...cli.ConfigResolver) cli.ConfigResolver {
119+
return func(flagName string) (string, bool) {
120+
for _, r := range resolvers {
121+
if v, ok := r(flagName); ok {
122+
return v, true
123+
}
124+
}
125+
return "", false
126+
}
127+
}

config/config_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package config_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/bjaus/cli/config"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestFromMap(t *testing.T) {
13+
t.Parallel()
14+
15+
m := map[string]string{"port": "8080", "host": "localhost"}
16+
resolver := config.FromMap(m)
17+
18+
val, ok := resolver("port")
19+
assert.True(t, ok)
20+
assert.Equal(t, "8080", val)
21+
22+
val, ok = resolver("host")
23+
assert.True(t, ok)
24+
assert.Equal(t, "localhost", val)
25+
26+
_, ok = resolver("missing")
27+
assert.False(t, ok)
28+
}
29+
30+
func TestFromJSON(t *testing.T) {
31+
t.Parallel()
32+
33+
t.Run("valid reader", func(t *testing.T) {
34+
t.Parallel()
35+
36+
r := strings.NewReader(`{"port": "9090", "host": "0.0.0.0"}`)
37+
resolver, err := config.FromJSON(r)
38+
require.NoError(t, err)
39+
40+
val, ok := resolver("port")
41+
assert.True(t, ok)
42+
assert.Equal(t, "9090", val)
43+
44+
_, ok = resolver("missing")
45+
assert.False(t, ok)
46+
})
47+
48+
t.Run("invalid JSON", func(t *testing.T) {
49+
t.Parallel()
50+
51+
r := strings.NewReader(`{not json`)
52+
_, err := config.FromJSON(r)
53+
require.Error(t, err)
54+
})
55+
}
56+
57+
func TestChain(t *testing.T) {
58+
t.Parallel()
59+
60+
first := config.FromMap(map[string]string{"port": "1111"})
61+
second := config.FromMap(map[string]string{"port": "2222", "host": "second"})
62+
63+
chained := config.Chain(first, second)
64+
65+
// First match wins.
66+
val, ok := chained("port")
67+
assert.True(t, ok)
68+
assert.Equal(t, "1111", val)
69+
70+
// Falls through to second.
71+
val, ok = chained("host")
72+
assert.True(t, ok)
73+
assert.Equal(t, "second", val)
74+
75+
// No match.
76+
_, ok = chained("missing")
77+
assert.False(t, ok)
78+
}

doc.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
// - counter — "true" to increment an int on each occurrence (-vvv)
6868
// - negatable — "true" to add a --no- prefix that sets a bool to false
6969
//
70-
// Priority: explicit flag > env var > default > zero value.
70+
// Priority: explicit flag > env var > config > default > zero value.
7171
//
7272
// Flags can appear anywhere — before or after subcommand names.
7373
//
@@ -101,6 +101,48 @@
101101
// Priority for automatic flag inheritance:
102102
// explicit child flag > child env var > inherited from parent > child default > zero value.
103103
//
104+
// # Config
105+
//
106+
// Flag values can be loaded from external configuration sources via a
107+
// [ConfigResolver]. A resolver is a single function:
108+
//
109+
// type ConfigResolver func(flagName string) (value string, found bool)
110+
//
111+
// Given a flag name, it returns the string value and whether it was found.
112+
// The framework handles all type conversion, validation, required checks,
113+
// and enum enforcement — the resolver only needs to return strings.
114+
//
115+
// Priority chain: explicit CLI flag > env var > config > default > zero value.
116+
//
117+
// Set a global resolver via [WithConfigResolver]:
118+
//
119+
// f, _ := os.Open("config.json")
120+
// resolver, _ := config.FromJSON(f)
121+
// cli.Execute(ctx, root, os.Args[1:],
122+
// cli.WithConfigResolver(resolver),
123+
// )
124+
//
125+
// Or implement [ConfigProvider] on a command for per-command resolvers:
126+
//
127+
// func (c *ServeCmd) ConfigResolver() cli.ConfigResolver {
128+
// return config.FromMap(map[string]string{"port": "9090"})
129+
// }
130+
//
131+
// Command-level resolvers take priority over the global resolver. Use
132+
// [config.Chain] to try multiple sources in order:
133+
//
134+
// resolver := config.Chain(
135+
// config.FromMap(overrides),
136+
// jsonResolver,
137+
// )
138+
//
139+
// The [config] subpackage ships [config.FromMap], [config.FromJSON], and
140+
// [config.Chain]. Because [ConfigResolver] is a plain function and
141+
// [config.FromMap] accepts any map[string]string, adding support for any
142+
// configuration format — YAML, TOML, HCL, .env files, remote stores —
143+
// is a matter of decoding into a map and calling [config.FromMap].
144+
// See the [config] package documentation for copy-paste adapter examples.
145+
//
104146
// # Extensibility
105147
//
106148
// Every major subsystem is replaceable:

example_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,3 +529,57 @@ func ExampleExecute_inheritTag() {
529529
_ = cli.Execute(context.Background(), app, []string{"--env", "staging", "serve"}, cli.WithStdout(os.Stdout)) //nolint:errcheck // example
530530
// Output: env=staging port=8080
531531
}
532+
533+
// ConfigResolver loads flag values from an external source.
534+
// Config values sit between defaults and env vars in priority:
535+
// explicit flag > env > config > default > zero.
536+
type ConfigServeCmd struct {
537+
Port int `flag:"port" default:"8080" help:"Listen port"`
538+
Host string `flag:"host" default:"localhost" help:"Host to bind to"`
539+
}
540+
541+
func (c *ConfigServeCmd) Run(_ context.Context, _ []string) error {
542+
fmt.Fprintf(os.Stdout, "host=%s port=%d\n", c.Host, c.Port) //nolint:errcheck // example output
543+
return nil
544+
}
545+
546+
func ExampleExecute_configResolver() {
547+
resolver := cli.ConfigResolver(func(flagName string) (string, bool) {
548+
m := map[string]string{"port": "9090", "host": "0.0.0.0"}
549+
v, ok := m[flagName]
550+
return v, ok
551+
})
552+
553+
cmd := &ConfigServeCmd{}
554+
_ = cli.Execute(context.Background(), cmd, nil, //nolint:errcheck // example
555+
cli.WithStdout(os.Stdout),
556+
cli.WithConfigResolver(resolver),
557+
)
558+
// Output: host=0.0.0.0 port=9090
559+
}
560+
561+
// ConfigProvider lets a command supply its own resolver.
562+
// The command-level resolver takes priority over the global one.
563+
type ConfigProviderCmd struct {
564+
Port int `flag:"port" default:"8080" help:"Listen port"`
565+
}
566+
567+
func (c *ConfigProviderCmd) Run(_ context.Context, _ []string) error {
568+
fmt.Fprintf(os.Stdout, "port=%d\n", c.Port) //nolint:errcheck // example output
569+
return nil
570+
}
571+
572+
func (c *ConfigProviderCmd) ConfigResolver() cli.ConfigResolver {
573+
return func(flagName string) (string, bool) {
574+
if flagName == "port" {
575+
return "3000", true
576+
}
577+
return "", false
578+
}
579+
}
580+
581+
func ExampleExecute_configProvider() {
582+
cmd := &ConfigProviderCmd{}
583+
_ = cli.Execute(context.Background(), cmd, nil, cli.WithStdout(os.Stdout)) //nolint:errcheck // example
584+
// Output: port=3000
585+
}

execute.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ func parseFlags(cmd Runner, args []string, opts *options) ([]string, map[string]
443443
remaining, err := opts.flagParser.ParseFlags(cmd, args)
444444
return remaining, nil, err
445445
}
446-
return defaultParseFlags(cmd, args)
446+
return defaultParseFlags(cmd, args, opts)
447447
}
448448

449449
func runAfterHooks(ctx context.Context, hooks []Runner) error {

0 commit comments

Comments
 (0)