Skip to content

Commit f9c0d7e

Browse files
committed
Redesign ConfigResolver to accept ConfigKey with decomposed parts
1 parent a0811ef commit f9c0d7e

8 files changed

Lines changed: 167 additions & 57 deletions

File tree

config/config.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@
6969
//
7070
// // Consul KV adapter
7171
// 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)
72+
// return func(key cli.ConfigKey) (string, bool) {
73+
// pair, _, err := client.KV().Get(prefix+"/"+key.Name, nil)
7474
// if err != nil || pair == nil { return "", false }
7575
// return string(pair.Value), true
7676
// }
@@ -97,8 +97,8 @@ import (
9797

9898
// FromMap returns a [cli.ConfigResolver] backed by a string map.
9999
func FromMap(m map[string]string) cli.ConfigResolver {
100-
return func(flagName string) (string, bool) {
101-
v, ok := m[flagName]
100+
return func(key cli.ConfigKey) (string, bool) {
101+
v, ok := m[key.Name]
102102
return v, ok
103103
}
104104
}
@@ -116,9 +116,9 @@ func FromJSON(r io.Reader) (cli.ConfigResolver, error) {
116116
// Chain returns a [cli.ConfigResolver] that tries each resolver in order,
117117
// returning the value from the first resolver that reports found.
118118
func Chain(resolvers ...cli.ConfigResolver) cli.ConfigResolver {
119-
return func(flagName string) (string, bool) {
119+
return func(key cli.ConfigKey) (string, bool) {
120120
for _, r := range resolvers {
121-
if v, ok := r(flagName); ok {
121+
if v, ok := r(key); ok {
122122
return v, true
123123
}
124124
}

config/config_test.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,31 @@ import (
44
"strings"
55
"testing"
66

7+
"github.com/bjaus/cli"
78
"github.com/bjaus/cli/config"
89
"github.com/stretchr/testify/assert"
910
"github.com/stretchr/testify/require"
1011
)
1112

13+
func key(name string) cli.ConfigKey {
14+
return cli.ConfigKey{Name: name, Parts: []string{name}}
15+
}
16+
1217
func TestFromMap(t *testing.T) {
1318
t.Parallel()
1419

1520
m := map[string]string{"port": "8080", "host": "localhost"}
1621
resolver := config.FromMap(m)
1722

18-
val, ok := resolver("port")
23+
val, ok := resolver(key("port"))
1924
assert.True(t, ok)
2025
assert.Equal(t, "8080", val)
2126

22-
val, ok = resolver("host")
27+
val, ok = resolver(key("host"))
2328
assert.True(t, ok)
2429
assert.Equal(t, "localhost", val)
2530

26-
_, ok = resolver("missing")
31+
_, ok = resolver(key("missing"))
2732
assert.False(t, ok)
2833
}
2934

@@ -37,11 +42,11 @@ func TestFromJSON(t *testing.T) {
3742
resolver, err := config.FromJSON(r)
3843
require.NoError(t, err)
3944

40-
val, ok := resolver("port")
45+
val, ok := resolver(key("port"))
4146
assert.True(t, ok)
4247
assert.Equal(t, "9090", val)
4348

44-
_, ok = resolver("missing")
49+
_, ok = resolver(key("missing"))
4550
assert.False(t, ok)
4651
})
4752

@@ -63,16 +68,16 @@ func TestChain(t *testing.T) {
6368
chained := config.Chain(first, second)
6469

6570
// First match wins.
66-
val, ok := chained("port")
71+
val, ok := chained(key("port"))
6772
assert.True(t, ok)
6873
assert.Equal(t, "1111", val)
6974

7075
// Falls through to second.
71-
val, ok = chained("host")
76+
val, ok = chained(key("host"))
7277
assert.True(t, ok)
7378
assert.Equal(t, "second", val)
7479

7580
// No match.
76-
_, ok = chained("missing")
81+
_, ok = chained(key("missing"))
7782
assert.False(t, ok)
7883
}

doc.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,11 @@
192192
// Flag values can be loaded from external configuration sources via a
193193
// [ConfigResolver]. A resolver is a single function:
194194
//
195-
// type ConfigResolver func(flagName string) (value string, found bool)
195+
// type ConfigResolver func(key ConfigKey) (value string, found bool)
196196
//
197-
// Given a flag name, it returns the string value and whether it was found.
197+
// Given a [ConfigKey], it returns the string value and whether it was found.
198+
// The key provides both the full flag name ([ConfigKey.Name]) and decomposed
199+
// parts ([ConfigKey.Parts]) for resolvers backed by nested formats.
198200
// The framework handles all type conversion, validation, required checks,
199201
// and enum enforcement — the resolver only needs to return strings.
200202
//

example_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -545,9 +545,9 @@ func (c *ConfigServeCmd) Run(_ context.Context, _ []string) error {
545545
}
546546

547547
func ExampleExecute_configResolver() {
548-
resolver := cli.ConfigResolver(func(flagName string) (string, bool) {
548+
resolver := cli.ConfigResolver(func(key cli.ConfigKey) (string, bool) {
549549
m := map[string]string{"port": "9090", "host": "0.0.0.0"}
550-
v, ok := m[flagName]
550+
v, ok := m[key.Name]
551551
return v, ok
552552
})
553553

@@ -571,8 +571,8 @@ func (c *ConfigProviderCmd) Run(_ context.Context, _ []string) error {
571571
}
572572

573573
func (c *ConfigProviderCmd) ConfigResolver() cli.ConfigResolver {
574-
return func(flagName string) (string, bool) {
575-
if flagName == "port" {
574+
return func(key cli.ConfigKey) (string, bool) {
575+
if key.Name == "port" {
576576
return "3000", true
577577
}
578578
return "", false

execute_test.go

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,9 +1285,9 @@ func (c *configCmd) Run(_ context.Context, _ []string) error { return nil }
12851285
func TestExecute_ConfigResolver_Applied(t *testing.T) {
12861286
t.Parallel()
12871287

1288-
resolver := cli.ConfigResolver(func(name string) (string, bool) {
1288+
resolver := cli.ConfigResolver(func(key cli.ConfigKey) (string, bool) {
12891289
m := map[string]string{"port": "9090", "host": "0.0.0.0"}
1290-
v, ok := m[name]
1290+
v, ok := m[key.Name]
12911291
return v, ok
12921292
})
12931293

@@ -1307,8 +1307,8 @@ func (c *envConfigCmd) Run(_ context.Context, _ []string) error { return nil }
13071307
func TestExecute_EnvOverridesConfig(t *testing.T) {
13081308
t.Setenv("CFG_PORT", "5555")
13091309

1310-
resolver := cli.ConfigResolver(func(name string) (string, bool) {
1311-
if name == "port" {
1310+
resolver := cli.ConfigResolver(func(key cli.ConfigKey) (string, bool) {
1311+
if key.Name == "port" {
13121312
return "9090", true
13131313
}
13141314
return "", false
@@ -1323,8 +1323,8 @@ func TestExecute_EnvOverridesConfig(t *testing.T) {
13231323
func TestExecute_ExplicitFlagOverridesConfig(t *testing.T) {
13241324
t.Parallel()
13251325

1326-
resolver := cli.ConfigResolver(func(name string) (string, bool) {
1327-
if name == "port" {
1326+
resolver := cli.ConfigResolver(func(key cli.ConfigKey) (string, bool) {
1327+
if key.Name == "port" {
13281328
return "9090", true
13291329
}
13301330
return "", false
@@ -1345,8 +1345,8 @@ func (c *reqConfigCmd) Run(_ context.Context, _ []string) error { return nil }
13451345
func TestExecute_ConfigSatisfiesRequired(t *testing.T) {
13461346
t.Parallel()
13471347

1348-
resolver := cli.ConfigResolver(func(name string) (string, bool) {
1349-
if name == "name" {
1348+
resolver := cli.ConfigResolver(func(key cli.ConfigKey) (string, bool) {
1349+
if key.Name == "name" {
13501350
return "alice", true
13511351
}
13521352
return "", false
@@ -1369,8 +1369,8 @@ func TestExecute_ConfigValidatedAgainstEnum(t *testing.T) {
13691369
// Valid enum value from config.
13701370
cmd := &enumConfigCmd{}
13711371
err := cli.Execute(context.Background(), cmd, nil, cli.WithConfigResolver(
1372-
cli.ConfigResolver(func(name string) (string, bool) {
1373-
if name == "format" {
1372+
cli.ConfigResolver(func(key cli.ConfigKey) (string, bool) {
1373+
if key.Name == "format" {
13741374
return "json", true
13751375
}
13761376
return "", false
@@ -1382,8 +1382,8 @@ func TestExecute_ConfigValidatedAgainstEnum(t *testing.T) {
13821382
// Invalid enum value from config.
13831383
cmd2 := &enumConfigCmd{}
13841384
err = cli.Execute(context.Background(), cmd2, nil, cli.WithConfigResolver(
1385-
cli.ConfigResolver(func(name string) (string, bool) {
1386-
if name == "format" {
1385+
cli.ConfigResolver(func(key cli.ConfigKey) (string, bool) {
1386+
if key.Name == "format" {
13871387
return "xml", true
13881388
}
13891389
return "", false
@@ -1400,8 +1400,8 @@ type configProviderCmd struct {
14001400
func (c *configProviderCmd) Run(_ context.Context, _ []string) error { return nil }
14011401

14021402
func (c *configProviderCmd) ConfigResolver() cli.ConfigResolver {
1403-
return func(name string) (string, bool) {
1404-
if name == "port" {
1403+
return func(key cli.ConfigKey) (string, bool) {
1404+
if key.Name == "port" {
14051405
return "4000", true
14061406
}
14071407
return "", false
@@ -1411,8 +1411,8 @@ func (c *configProviderCmd) ConfigResolver() cli.ConfigResolver {
14111411
func TestExecute_ConfigProvider_OverridesGlobal(t *testing.T) {
14121412
t.Parallel()
14131413

1414-
global := cli.ConfigResolver(func(name string) (string, bool) {
1415-
if name == "port" {
1414+
global := cli.ConfigResolver(func(key cli.ConfigKey) (string, bool) {
1415+
if key.Name == "port" {
14161416
return "9090", true
14171417
}
14181418
return "", false

flags.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ func flagTypeName(t reflect.Type) string {
139139
type fieldInfo struct {
140140
index []int // field path for nested structs (e.g. [0, 2] for embedded.Field)
141141
def FlagDef
142+
parts []string // decomposed flag name: prefix segments + base (e.g. ["db", "host"])
142143
provided bool
143144
envOnly bool // standalone env field — not a CLI flag, only env/config/default
144145
}
@@ -239,27 +240,28 @@ func collectProvided(fields map[string]*fieldInfo) map[string]bool {
239240

240241
func buildFieldMap(t reflect.Type) map[string]*fieldInfo {
241242
fields := make(map[string]*fieldInfo)
242-
buildFieldMapRecurse(t, fields, nil, "")
243+
buildFieldMapRecurse(t, fields, nil, "", nil)
243244
return fields
244245
}
245246

246-
func buildFieldMapRecurse(t reflect.Type, fields map[string]*fieldInfo, indexPath []int, prefix string) {
247+
func buildFieldMapRecurse(t reflect.Type, fields map[string]*fieldInfo, indexPath []int, prefix string, parts []string) {
247248
for i := range t.NumField() {
248249
f := t.Field(i)
249250
currentPath := append(append([]int{}, indexPath...), i)
250251

251252
// Named struct with prefix tag: recurse with prefix.
252253
if f.Type.Kind() == reflect.Struct && !f.Anonymous {
253254
if pfx := f.Tag.Get("prefix"); pfx != "" {
254-
buildFieldMapRecurse(f.Type, fields, currentPath, prefix+pfx)
255+
part := strings.TrimRight(pfx, "-._/")
256+
buildFieldMapRecurse(f.Type, fields, currentPath, prefix+pfx, append(parts, part))
255257
continue
256258
}
257259
// Fall through: may be a custom type with flag tag (e.g. FlagUnmarshaler).
258260
}
259261

260262
// Anonymous embedded struct (non-pointer): promote fields.
261263
if f.Anonymous && f.Type.Kind() == reflect.Struct {
262-
buildFieldMapRecurse(f.Type, fields, currentPath, prefix)
264+
buildFieldMapRecurse(f.Type, fields, currentPath, prefix, parts)
263265
continue
264266
}
265267

@@ -281,6 +283,7 @@ func buildFieldMapRecurse(t reflect.Type, fields map[string]*fieldInfo, indexPat
281283
}
282284

283285
fullName := prefix + name
286+
fieldParts := append(append([]string{}, parts...), name)
284287

285288
var aliases []string
286289
if raw := f.Tag.Get("alt"); raw != "" {
@@ -301,6 +304,7 @@ func buildFieldMapRecurse(t reflect.Type, fields map[string]*fieldInfo, indexPat
301304

302305
fi := &fieldInfo{
303306
index: currentPath,
307+
parts: fieldParts,
304308
envOnly: envOnly,
305309
def: FlagDef{
306310
Name: fullName,
@@ -360,7 +364,7 @@ func applyConfig(v reflect.Value, fields map[string]*fieldInfo, resolver ConfigR
360364
return nil
361365
}
362366
for _, fi := range fields {
363-
val, found := resolver(fi.def.Name)
367+
val, found := resolver(ConfigKey{Name: fi.def.Name, Parts: fi.parts})
364368
if !found {
365369
continue
366370
}

interfaces.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,21 @@ type Suggester interface {
128128

129129
// --- Config interfaces (all optional) ---
130130

131+
// ConfigKey identifies a flag for config resolution. Name is the full
132+
// prefixed flag name (e.g. "db-host"). Parts decomposes the name into
133+
// prefix segments and base name (e.g. ["db", "host"]), useful for
134+
// resolvers backed by nested configuration formats (YAML, TOML).
135+
// For unprefixed flags, Parts contains a single element equal to Name.
136+
type ConfigKey struct {
137+
Name string
138+
Parts []string
139+
}
140+
131141
// ConfigResolver resolves flag values from an external source such as a
132-
// config file. Given a flag name, it returns the string value and whether
133-
// the flag was found. The framework handles type conversion.
134-
type ConfigResolver func(flagName string) (value string, found bool)
142+
// config file. Given a [ConfigKey], it returns the string value and whether
143+
// the flag was found. The framework handles type conversion. Use
144+
// [ConfigKey.Name] for flat lookups or [ConfigKey.Parts] for nested lookups.
145+
type ConfigResolver func(key ConfigKey) (value string, found bool)
135146

136147
// ConfigProvider is implemented by commands that supply their own resolver.
137148
// Checked before the global resolver set via [WithConfigResolver].

0 commit comments

Comments
 (0)