Add configuration scope suite registration

This commit is contained in:
2026-10-07 13:44:46 +00:00
parent ecd2b19783
commit cc9b0eddab
9 changed files with 781 additions and 5 deletions

5
app.go
View File

@@ -4,8 +4,9 @@ import "fmt"
// App owns one command registry and its application identity.
type App struct {
identity Identity
commands map[string]*commandNode
identity Identity
commands map[string]*commandNode
configRegistered bool
}
type commandNode struct {

View File

@@ -18,6 +18,7 @@ func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V,
return Flag[I]{binding: binding}
}
binding.valid = true
binding.clear = func(input *I) { set(input, Supplied[V]{}) }
binding.prepare = func(values []string) (func(*I), error) {
value := fallback
supplied := len(values) > 0
@@ -42,6 +43,7 @@ func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[boo
return Flag[I]{binding: binding}
}
binding.valid = true
binding.clear = func(input *I) { set(input, Supplied[bool]{}) }
binding.prepare = func(values []string) (func(*I), error) {
value := defaultValue
supplied := len(values) > 0
@@ -67,6 +69,7 @@ func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, er
return Flag[I]{binding: binding}
}
binding.valid = true
binding.clear = func(input *I) { set(input, Supplied[[]V]{}) }
binding.prepare = func(values []string) (func(*I), error) {
parsedValues := append([]V(nil), fallback...)
supplied := len(values) > 0

View File

@@ -57,6 +57,7 @@ type flagBinding[I any] struct {
repeated bool
boolean bool
prepare func([]string) (func(*I), error)
clear func(*I)
valid bool
}

324
config.go Normal file
View File

@@ -0,0 +1,324 @@
package commandkit
import (
"context"
"fmt"
"io"
"sort"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
// Selection identifies the application configuration inputs supplied to a
// configuration scope.
type Selection struct {
Config Supplied[string]
Overlay Supplied[string]
}
// ConfigOperation identifies a supported configuration inspection operation.
type ConfigOperation string
const (
ConfigValidate ConfigOperation = "validate"
ConfigShow ConfigOperation = "show"
ConfigSources ConfigOperation = "sources"
ConfigExplain ConfigOperation = "explain"
)
// ConfigRequest describes one configuration operation and its selected inputs.
type ConfigRequest struct {
Operation ConfigOperation
Selection Selection
}
// EffectiveWriter writes an owning library's native effective configuration.
type EffectiveWriter interface {
WriteEffective(io.Writer) error
}
// SourceReader reports the sources used to construct a configuration.
type SourceReader interface {
Sources() []configkit.SourceInfo
}
// ExplanationReader writes an owning library's path explanation.
type ExplanationReader interface {
Explain(configkit.Path) (configkit.Explanation, bool)
WriteExplanation(io.Writer, configkit.Path) error
}
// ConfigView contains optional views relevant to supported operations.
type ConfigView struct {
Effective EffectiveWriter
Sources SourceReader
Explanation ExplanationReader
}
// Acquired contains a configuration view and diagnostics from its acquisition.
type Acquired[T any] struct {
Value T
Diagnostics []Diagnostic
}
// ConfigScope declares one named configuration and its supported operations.
type ConfigScope[C any] struct {
Name string
Summary string
Operations []ConfigOperation
Acquire func(context.Context, ConfigRequest, C) (Acquired[ConfigView], error)
}
// Selector binds one typed command flag to selected configuration scopes.
type Selector[C any] struct {
Binding Flag[C]
Scopes []string
}
// ConfigSuite declares selectors and configuration scopes for an App.
type ConfigSuite[C any] struct {
NewContext func() C
Selectors []Selector[C]
CheckInput func(Selection, C) error
Scopes []ConfigScope[C]
}
type configScopeInfo struct {
Name string `yaml:"name"`
Operations []ConfigOperation `yaml:"operations"`
}
type scopesInventory struct {
Scopes []configScopeInfo `yaml:"scopes"`
}
type configScopesInput struct {
Format string
}
type selectorState[C any] struct {
scopes []string
supplied bool
apply func(*C)
clear func(*C)
}
type configInput[C any] struct {
Selection Selection
Scope string
Format string
SelectorContext C
selectors []selectorState[C]
}
// RegisterConfig installs the config family and its scope discovery command.
func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
if app == nil {
return fmt.Errorf("cannot register configuration on a nil app")
}
if app.configRegistered {
return fmt.Errorf("configuration suite is already registered")
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
return err
}
if _, exists := app.commands["config"]; exists {
return fmt.Errorf("command path %q is already registered", "config")
}
command, err := newConfigScopesCommandWithScopes(normalized.Scopes, nil)
if err != nil {
return err
}
root, err := Group("config", "Inspect configuration.", command)
if err != nil {
return err
}
if err := app.Register(root); err != nil {
return err
}
app.configRegistered = true
return nil
}
func normalizeConfigSuite[C any](suite ConfigSuite[C]) (ConfigSuite[C], error) {
normalized := suite
normalized.Selectors = append([]Selector[C](nil), suite.Selectors...)
normalized.Scopes = make([]ConfigScope[C], 0, len(suite.Scopes))
scopeNames := make(map[string]struct{}, len(suite.Scopes))
for _, scope := range suite.Scopes {
if scope.Name == "" {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope name must not be empty")
}
if scope.Name == "all" {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope name %q is reserved", scope.Name)
}
if _, exists := scopeNames[scope.Name]; exists {
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration scope %q", scope.Name)
}
scopeNames[scope.Name] = struct{}{}
operations, err := normalizeConfigOperations(scope.Operations)
if err != nil {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope %q: %w", scope.Name, err)
}
if len(operations) > 0 && scope.Acquire == nil {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope %q declares operations without an acquisition callback", scope.Name)
}
scope.Operations = operations
normalized.Scopes = append(normalized.Scopes, scope)
}
for index := range normalized.Selectors {
selector := &normalized.Selectors[index]
binding := selector.Binding.binding
if !binding.valid || binding.clear == nil || binding.meta.Name == "" {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector %d has an invalid flag binding", index)
}
if !validFlagName(binding.meta.Name) {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector flag name %q is invalid", binding.meta.Name)
}
if binding.meta.Name == "config" || binding.meta.Name == "overlay" || binding.meta.Name == "scope" || binding.meta.Name == "format" || binding.meta.Name == "help" {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector flag --%s conflicts with a standard flag", binding.meta.Name)
}
if binding.meta.Short == "h" {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector short flag -%s conflicts with help", binding.meta.Short)
}
if binding.meta.Short != "" && (len(binding.meta.Short) != 1 || binding.meta.Short == "-") {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector short spelling %q must be one byte", binding.meta.Short)
}
seen := make(map[string]struct{}, len(selector.Scopes))
unique := make([]string, 0, len(selector.Scopes))
for _, scopeName := range selector.Scopes {
if _, exists := scopeNames[scopeName]; !exists {
return ConfigSuite[C]{}, fmt.Errorf("selector --%s references unknown configuration scope %q", binding.meta.Name, scopeName)
}
if _, exists := seen[scopeName]; exists {
continue
}
seen[scopeName] = struct{}{}
unique = append(unique, scopeName)
}
selector.Scopes = append([]string(nil), unique...)
for previous := 0; previous < index; previous++ {
other := normalized.Selectors[previous].Binding.binding.meta
if other.Name == binding.meta.Name || binding.meta.Short != "" && other.Short == binding.meta.Short {
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration selector flag --%s", binding.meta.Name)
}
}
}
return normalized, nil
}
func normalizeConfigOperations(operations []ConfigOperation) ([]ConfigOperation, error) {
seen := make(map[ConfigOperation]struct{}, len(operations))
for _, operation := range operations {
switch operation {
case ConfigValidate, ConfigShow, ConfigSources, ConfigExplain:
default:
return nil, fmt.Errorf("unknown operation %q", operation)
}
seen[operation] = struct{}{}
}
ordered := make([]ConfigOperation, 0, len(seen))
for _, operation := range []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain} {
if _, exists := seen[operation]; exists {
ordered = append(ordered, operation)
}
}
return ordered, nil
}
func newConfigScopesCommandWithScopes[C any](scopes []ConfigScope[C], installed []ConfigOperation) (Command, error) {
formatDefault := "text"
spec := CommandSpec[configScopesInput]{
Name: "scopes",
Summary: "List configuration scopes and supported operations.",
Flags: []Flag[configScopesInput]{ValueFlag[configScopesInput](FlagMeta{
Name: "format", Usage: "Output format: text or yaml.",
}, &formatDefault, parseConfigFormat, func(input *configScopesInput, value Supplied[string]) {
input.Format = value.Value
})},
CheckInput: func(input configScopesInput) error { return checkConfigFormat(input.Format) },
Run: func(invocation Invocation, input configScopesInput) Outcome {
inventory := scopesInventory{Scopes: make([]configScopeInfo, 0, len(scopes))}
for _, scope := range orderedConfigScopes(scopes) {
operations := make([]ConfigOperation, 0, len(scope.Operations))
for _, operation := range scope.Operations {
if containsOperation(installed, operation) {
operations = append(operations, operation)
}
}
inventory.Scopes = append(inventory.Scopes, configScopeInfo{Name: scope.Name, Operations: operations})
}
var output []byte
var err error
if input.Format == "yaml" {
output, err = yaml.Marshal(inventory)
} else {
output = []byte(formatScopeInventory(inventory))
}
if err != nil {
return Failure(err)
}
if err := writeReport(invocation.Streams.Out, output); err != nil {
return incompleteReport(err)
}
return Success()
},
}
return NewCommand(spec)
}
func orderedConfigScopes[C any](scopes []ConfigScope[C]) []ConfigScope[C] {
ordered := append([]ConfigScope[C](nil), scopes...)
sort.SliceStable(ordered, func(i, j int) bool {
return configScopeOrder(ordered[i].Name) < configScopeOrder(ordered[j].Name)
})
return ordered
}
func configScopeOrder(name string) string {
switch name {
case "app":
return "\x00"
case "promptkit":
return "\x01"
default:
return "\x02" + name
}
}
func formatScopeInventory(inventory scopesInventory) string {
var output strings.Builder
for _, scope := range inventory.Scopes {
output.WriteString(scope.Name)
output.WriteString(":")
if len(scope.Operations) > 0 {
output.WriteString(" ")
for index, operation := range scope.Operations {
if index > 0 {
output.WriteString(", ")
}
output.WriteString(string(operation))
}
}
output.WriteByte('\n')
}
return output.String()
}
func parseConfigFormat(value string) (string, error) {
if err := checkConfigFormat(value); err != nil {
return "", err
}
return value, nil
}
func checkConfigFormat(value string) error {
if value != "text" && value != "yaml" {
return fmt.Errorf("format must be text or yaml")
}
return nil
}

219
config_test.go Normal file
View File

@@ -0,0 +1,219 @@
package commandkit
import (
"bytes"
"context"
"reflect"
"strconv"
"strings"
"testing"
)
func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) {
app, err := New(Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
newContextCalls := 0
acquireCalls := 0
suite := ConfigSuite[struct{}]{
NewContext: func() struct{} { newContextCalls++; return struct{}{} },
Scopes: []ConfigScope[struct{}]{
{Name: "zeta", Operations: []ConfigOperation{ConfigExplain, ConfigShow, ConfigExplain}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
acquireCalls++
return Acquired[ConfigView]{}, nil
}},
{Name: "promptkit", Operations: []ConfigOperation{ConfigSources}, Acquire: noopConfigAcquire[struct{}]},
{Name: "app", Operations: []ConfigOperation{ConfigShow, ConfigValidate, ConfigShow}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
acquireCalls++
return Acquired[ConfigView]{}, nil
}},
{Name: "metadata", Summary: "No provider is required."},
},
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(normalized.Scopes[0].Operations, []ConfigOperation{ConfigShow, ConfigExplain}) || !reflect.DeepEqual(normalized.Scopes[2].Operations, []ConfigOperation{ConfigValidate, ConfigShow}) {
t.Fatalf("normalized capabilities = %#v", normalized.Scopes)
}
if err := RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var textOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, Streams{Out: &textOutput})
if outcome.Kind != OutcomeSuccess {
t.Fatalf("text inventory outcome = %#v", outcome)
}
wantText := "app:\npromptkit:\nmetadata:\nzeta:\n"
if textOutput.String() != wantText {
t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText)
}
var yamlOutput bytes.Buffer
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--format", "yaml"}, Streams{Out: &yamlOutput})
if outcome.Kind != OutcomeSuccess {
t.Fatalf("YAML inventory outcome = %#v", outcome)
}
if got := yamlOutput.String(); !strings.Contains(got, "scopes:") || strings.Index(got, "name: app") > strings.Index(got, "name: promptkit") || !strings.Contains(got, "operations: []") {
t.Fatalf("YAML inventory = %q", got)
}
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--help"}, Streams{})
if outcome.Kind != OutcomeSuccess {
t.Fatalf("scope help outcome = %#v", outcome)
}
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--format", "json"}, Streams{})
if outcome.Kind != OutcomeUsage {
t.Fatalf("invalid format outcome = %#v", outcome)
}
if newContextCalls != 0 || acquireCalls != 0 {
t.Fatalf("discovery called runtime callbacks: NewContext=%d Acquire=%d", newContextCalls, acquireCalls)
}
}
func TestConfigInputDefaultsToAppAndRetainsAbsentSelections(t *testing.T) {
suite := ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{{Name: "app"}}}
prepared, err := prepareInput(CommandSpec[configInput[struct{}]]{Flags: configInputFlags(suite, "yaml")}, make([][]string, 4), nil, true)
if err != nil {
t.Fatal(err)
}
input := prepared.build()
if input.Scope != "app" || input.Format != "yaml" || input.Selection.Config.Set || input.Selection.Overlay.Set {
t.Fatalf("configuration defaults = %#v", input)
}
}
func TestRegisterConfigRejectsInvalidSuitesAndCollisions(t *testing.T) {
tests := []struct {
name string
scope ConfigScope[struct{}]
want string
}{
{name: "empty name", scope: ConfigScope[struct{}]{}, want: "name must not be empty"},
{name: "all name", scope: ConfigScope[struct{}]{Name: "all"}, want: "reserved"},
{name: "unknown operation", scope: ConfigScope[struct{}]{Name: "app", Operations: []ConfigOperation{"diff"}}, want: "unknown operation"},
{name: "missing acquisition", scope: ConfigScope[struct{}]{Name: "app", Operations: []ConfigOperation{ConfigValidate}}, want: "without an acquisition"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
app, _ := New(Identity{Name: "example"})
err := RegisterConfig(app, ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{test.scope}})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("RegisterConfig error = %v, want %q", err, test.want)
}
})
}
app, _ := New(Identity{Name: "example"})
custom, err := NewCommand(CommandSpec[struct{}]{Name: "config", Run: func(Invocation, struct{}) Outcome { return Success() }})
if err != nil {
t.Fatal(err)
}
if err := app.Register(custom); err != nil {
t.Fatal(err)
}
if err := RegisterConfig(app, ConfigSuite[struct{}]{}); err == nil {
t.Fatal("replaced a pre-existing config command")
}
}
func TestConfigSelectorPreflightAndScopedContextProjection(t *testing.T) {
type selectorInput struct {
Count Supplied[int]
Enabled Supplied[bool]
Other Supplied[int]
Runtime string
}
baseContextCalls := 0
checkCalls := 0
var checked selectorInput
countDefault := 9
suite := ConfigSuite[selectorInput]{
NewContext: func() selectorInput { baseContextCalls++; return selectorInput{Runtime: "invocation cache"} },
Selectors: []Selector[selectorInput]{
{Binding: ValueFlag[selectorInput](FlagMeta{Name: "count"}, &countDefault, strconv.Atoi, func(input *selectorInput, value Supplied[int]) { input.Count = value }), Scopes: []string{"app"}},
{Binding: BoolFlag[selectorInput](FlagMeta{Name: "enabled"}, true, func(input *selectorInput, value Supplied[bool]) { input.Enabled = value }), Scopes: []string{"promptkit"}},
{Binding: ValueFlag[selectorInput](FlagMeta{Name: "other"}, nil, strconv.Atoi, func(input *selectorInput, value Supplied[int]) { input.Other = value })},
},
CheckInput: func(_ Selection, input selectorInput) error { checkCalls++; checked = input; return nil },
Scopes: []ConfigScope[selectorInput]{
{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
{Name: "promptkit", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
},
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
t.Fatal(err)
}
spec := CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}
prepared, err := prepareInput(spec, [][]string{{"all"}, {"text"}, {""}, nil, {"0"}, {"false"}, nil}, nil, true)
if err != nil {
t.Fatal(err)
}
input := prepared.build()
scopeNames, err := preflightConfig(normalized, input, ConfigValidate)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(scopeNames, []string{"app", "promptkit"}) || checkCalls != 1 || checked.Count != (Supplied[int]{Value: 0, Set: true}) || checked.Enabled != (Supplied[bool]{Value: false, Set: true}) {
t.Fatalf("preflight result=%v checked=%#v calls=%d", scopeNames, checked, checkCalls)
}
if !input.Selection.Config.Set || input.Selection.Config.Value != "" || input.Selection.Overlay.Set {
t.Fatalf("configuration selection presence = %#v", input.Selection)
}
if baseContextCalls != 0 {
t.Fatalf("NewContext called during preflight %d times", baseContextCalls)
}
contexts := contextsForScopes(normalized, input, scopeNames)
if baseContextCalls != 1 {
t.Fatalf("NewContext calls = %d, want one", baseContextCalls)
}
if contexts["app"].Count != (Supplied[int]{Value: 0, Set: true}) || contexts["app"].Enabled != (Supplied[bool]{}) || contexts["app"].Other != (Supplied[int]{}) {
t.Fatalf("app selectors = %#v", contexts["app"])
}
if contexts["promptkit"].Count != (Supplied[int]{}) || contexts["promptkit"].Enabled != (Supplied[bool]{Value: false, Set: true}) || contexts["promptkit"].Runtime != "invocation cache" {
t.Fatalf("promptkit selectors = %#v", contexts["promptkit"])
}
}
func TestConfigPreflightRejectsUnsupportedAggregateAndInapplicableSelector(t *testing.T) {
type selectorInput struct{ Region Supplied[string] }
newContextCalls := 0
suite := ConfigSuite[selectorInput]{
NewContext: func() selectorInput { newContextCalls++; return selectorInput{} },
Selectors: []Selector[selectorInput]{{Binding: ValueFlag[selectorInput](FlagMeta{Name: "region"}, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.Region = value }), Scopes: []string{"promptkit"}}},
Scopes: []ConfigScope[selectorInput]{
{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
{Name: "promptkit", Operations: []ConfigOperation{ConfigShow}, Acquire: noopConfigAcquire[selectorInput]},
},
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
t.Fatal(err)
}
prepared, err := prepareInput(CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}, [][]string{{"all"}, {"text"}, nil, nil, nil}, nil, true)
if err != nil {
t.Fatal(err)
}
if _, err := preflightConfig(normalized, prepared.build(), ConfigValidate); err == nil || !strings.Contains(err.Error(), "does not support validate") {
t.Fatalf("unsupported aggregate error = %v", err)
}
if newContextCalls != 0 {
t.Fatalf("unsupported aggregate created context %d times", newContextCalls)
}
prepared, err = prepareInput(CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}, [][]string{{"app"}, {"text"}, nil, nil, {"west"}}, nil, true)
if err != nil {
t.Fatal(err)
}
if _, err := preflightConfig(normalized, prepared.build(), ConfigValidate); err == nil || !strings.Contains(err.Error(), "not applicable") {
t.Fatalf("inapplicable selector error = %v", err)
}
}
func noopConfigAcquire[C any](context.Context, ConfigRequest, C) (Acquired[ConfigView], error) {
return Acquired[ConfigView]{}, nil
}

View File

@@ -83,6 +83,25 @@ Typed inputs keep supplied state separate from defaults. An explicit empty,
false, or zero value is supplied. Help and version are available without
constructing application command inputs.
## Configuration scopes
An application installs one configuration suite with `RegisterConfig`. Its
`config scopes` command lists scope names and the configuration operations
currently installed for those scopes:
```text
example config scopes
example config scopes --format yaml
```
Text output uses one `scope: operation, operation` line per scope. YAML uses a
`scopes` sequence with explicit `name` and `operations` fields. Scope order is
`app`, then `promptkit`, then other names alphabetically. A metadata-only scope
can be listed without an acquisition callback. Discovery does not construct a
runtime context or acquire configuration. It reports the intersection between
declared scope capabilities and installed handlers, so an operation is listed
only when the corresponding command is available.
## Outcome status
CommandKit usage failures use status `2`; operational failures use status `1`.

View File

@@ -4,7 +4,8 @@ CommandKit currently contains adopted repository policies, accepted architectura
decisions, implementation plans, identity and outcome primitives, typed command
declarations, an instance-owned registry, a Cobra-backed routing and parsing
adapter, public `App.Execute` coordination, and generated help/version commands.
Standard command families remain pending. The
Configuration suite registration and scope discovery are implemented; the
configuration operations remain pending. The
[README](../README.md) provides project orientation; the
[documentation policy](policy/documentation.md#canonical-ownership) assigns
canonical documentation owners.

View File

@@ -1,6 +1,6 @@
# Initial CommandKit implementation plan
Status: implementation underway; Stages 1-6 complete and Stages 7-14 pending.
Status: implementation underway; Stages 1-7 complete and Stages 8-14 pending.
Date: 2026-10-07.
@@ -448,7 +448,7 @@ implemented boundary.
## Stage 7: implement configuration suites, scopes, and selector preflight
**Status:** pending. **Prerequisite:** Stage 6.
**Status:** complete. **Prerequisite:** Stage 6.
Add the following root contracts in `config.go`/`selectors.go`:
@@ -539,6 +539,16 @@ unsupported all before callbacks, explicit/omitted selector applicability,
typed empty/false/zero context selectors, and factories uninvoked on help/usage.
Use a small custom scope fixture; owning library helpers arrive next.
**Completion evidence:** added typed configuration suite, scope, selector, view,
and request contracts; validates scope IDs, capability metadata, selector
bindings, and family collisions. `config scopes` reports installed capability
intersections in stable text/YAML inventories without creating a runtime context
or acquiring configuration. Added selector preflight and per-scope context
projection for later operation handlers, including explicit empty/false/zero
values and clearing inapplicable bound fields. `go test ./...` passes; the CLI
reference describes scope discovery. Configuration operation handlers remain
sequenced in Stages 8-11.
## Stage 8: integrate ConfigKit and implement validation
**Status:** pending. **Prerequisite:** Stage 7.

198
selectors.go Normal file
View File

@@ -0,0 +1,198 @@
package commandkit
import (
"fmt"
"sort"
"strings"
)
func configInputFlags[C any](suite ConfigSuite[C], formatDefault string) []Flag[configInput[C]] {
scopeDefault := "app"
flags := []Flag[configInput[C]]{
ValueFlag[configInput[C]](FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported."}, &scopeDefault, parseString, func(input *configInput[C], value Supplied[string]) {
input.Scope = value.Value
}),
ValueFlag[configInput[C]](FlagMeta{Name: "format", Usage: "Output format."}, &formatDefault, parseString, func(input *configInput[C], value Supplied[string]) {
input.Format = value.Value
}),
ValueFlag[configInput[C]](FlagMeta{Name: "config", Usage: "Configuration source."}, nil, parseString, func(input *configInput[C], value Supplied[string]) {
input.Selection.Config = value
}),
ValueFlag[configInput[C]](FlagMeta{Name: "overlay", Usage: "Overlay source."}, nil, parseString, func(input *configInput[C], value Supplied[string]) {
input.Selection.Overlay = value
}),
}
for _, selector := range suite.Selectors {
flags = append(flags, adaptSelector(selector))
}
return flags
}
func adaptSelector[C any](selector Selector[C]) Flag[configInput[C]] {
binding := selector.Binding.binding
meta := binding.meta
if len(selector.Scopes) == 0 {
meta.Usage += " (applies to all scopes)"
} else {
meta.Usage += " (applies to: " + joinNames(selector.Scopes) + ")"
}
return Flag[configInput[C]]{binding: flagBinding[configInput[C]]{
meta: meta,
defaultText: binding.defaultText,
repeated: binding.repeated,
boolean: binding.boolean,
valid: binding.valid,
prepare: func(values []string) (func(*configInput[C]), error) {
apply, err := binding.prepare(values)
if err != nil {
return nil, err
}
state := selectorState[C]{
scopes: append([]string(nil), selector.Scopes...),
supplied: len(values) > 0,
apply: apply,
clear: binding.clear,
}
return func(input *configInput[C]) {
apply(&input.SelectorContext)
input.selectors = append(input.selectors, state)
}, nil
},
}}
}
func parseString(value string) (string, error) { return value, nil }
func preflightConfig[C any](suite ConfigSuite[C], input configInput[C], operation ConfigOperation) ([]string, error) {
if err := checkConfigFormat(input.Format); err != nil {
return nil, err
}
if input.Scope == "all" {
if operation != ConfigValidate && operation != ConfigShow {
return nil, fmt.Errorf("scope all is supported only for validate and show")
}
} else if input.Scope == "" {
return nil, fmt.Errorf("configuration scope must not be empty")
}
scopes := make([]string, 0, len(suite.Scopes))
if input.Scope == "all" {
for _, scope := range orderedConfigScopes(suite.Scopes) {
scopes = append(scopes, scope.Name)
}
} else {
scope, ok := findConfigScope(suite.Scopes, input.Scope)
if !ok {
return nil, fmt.Errorf("unknown configuration scope %q", input.Scope)
}
if !containsOperation(scope.Operations, operation) {
return nil, unsupportedConfigOperation(scope, operation)
}
scopes = append(scopes, scope.Name)
}
if input.Scope == "all" {
for _, scopeName := range scopes {
scope, _ := findConfigScope(suite.Scopes, scopeName)
if !containsOperation(scope.Operations, operation) {
return nil, unsupportedConfigOperation(scope, operation)
}
}
}
for _, selector := range input.selectors {
if !selector.supplied {
continue
}
applies := false
for _, scopeName := range scopes {
if selectorApplies(selector.scopes, scopeName) {
applies = true
break
}
}
if !applies {
return nil, fmt.Errorf("selector is not applicable to configuration scope %q", input.Scope)
}
}
if suite.CheckInput != nil {
if err := suite.CheckInput(input.Selection, input.SelectorContext); err != nil {
return nil, err
}
}
return scopes, nil
}
func contextsForScopes[C any](suite ConfigSuite[C], input configInput[C], scopeNames []string) map[string]C {
base := *new(C)
if suite.NewContext != nil {
base = suite.NewContext()
}
contexts := make(map[string]C, len(scopeNames))
for _, name := range scopeNames {
context := base
for _, selector := range input.selectors {
if selectorApplies(selector.scopes, name) {
selector.apply(&context)
} else {
selector.clear(&context)
}
}
contexts[name] = context
}
return contexts
}
func findConfigScope[C any](scopes []ConfigScope[C], name string) (ConfigScope[C], bool) {
for _, scope := range scopes {
if scope.Name == name {
return scope, true
}
}
return ConfigScope[C]{}, false
}
func containsOperation(operations []ConfigOperation, requested ConfigOperation) bool {
for _, operation := range operations {
if operation == requested {
return true
}
}
return false
}
func unsupportedConfigOperation[C any](scope ConfigScope[C], operation ConfigOperation) error {
return fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.Name, operation, joinOperations(scope.Operations))
}
func selectorApplies(scopes []string, name string) bool {
if len(scopes) == 0 {
return true
}
for _, scope := range scopes {
if scope == name {
return true
}
}
return false
}
func joinNames(names []string) string {
ordered := append([]string(nil), names...)
sort.Strings(ordered)
return joinStrings(ordered)
}
func joinOperations(operations []ConfigOperation) string {
values := make([]string, len(operations))
for index, operation := range operations {
values[index] = string(operation)
}
return joinStrings(values)
}
func joinStrings(values []string) string {
if len(values) == 0 {
return "none"
}
return strings.Join(values, ", ")
}