diff --git a/app.go b/app.go index f3ac4ec..135b246 100644 --- a/app.go +++ b/app.go @@ -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 { diff --git a/binding.go b/binding.go index a74492f..f05329d 100644 --- a/binding.go +++ b/binding.go @@ -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 diff --git a/command.go b/command.go index e71a82c..8977eeb 100644 --- a/command.go +++ b/command.go @@ -57,6 +57,7 @@ type flagBinding[I any] struct { repeated bool boolean bool prepare func([]string) (func(*I), error) + clear func(*I) valid bool } diff --git a/config.go b/config.go new file mode 100644 index 0000000..3ce7f91 --- /dev/null +++ b/config.go @@ -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 +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..dfdb110 --- /dev/null +++ b/config_test.go @@ -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 +} diff --git a/docs/cli.md b/docs/cli.md index edf580e..034cb5a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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`. diff --git a/docs/development.md b/docs/development.md index 5a1763d..c7ff5b7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 4f5379a..6294f55 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/selectors.go b/selectors.go new file mode 100644 index 0000000..906afcb --- /dev/null +++ b/selectors.go @@ -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, ", ") +}