Add configuration scope suite registration
This commit is contained in:
5
app.go
5
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
324
config.go
Normal 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
219
config_test.go
Normal 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
|
||||
}
|
||||
19
docs/cli.md
19
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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
198
selectors.go
Normal 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, ", ")
|
||||
}
|
||||
Reference in New Issue
Block a user