package ckconfig import ( "bytes" "context" "errors" "fmt" "io" "sort" "strings" commandkit "gitea.maximumdirect.net/eric/CommandKit" configkit "gitea.maximumdirect.net/eric/ConfigKit" ) type scopeMetadata struct { Name string `yaml:"name"` Operations []Operation `yaml:"operations"` } type scopeInventory struct { Scopes []scopeMetadata `yaml:"scopes"` } type preparedScope[I, S any] struct { scope Scope[I, S] bindings []commandkit.Flag[I] } type preparedSuite[I, S any] struct { newInput func() I newState func() S checkInput func(string, Selection, I) error scopes []*preparedScope[I, S] byName map[string]*preparedScope[I, S] bindingsByOperation map[Operation][]commandkit.Flag[I] scopeChoices string operations []Operation inventory scopeInventory issues []error } // Command eagerly captures a configuration suite as a root-only config family. // It copies suite slices, retains callback references, and returns a reusable // declaration; installation does not invoke factories or providers. During an // inspection, it initializes input separately for each selected scope. A // comparison initializes and checks both sides before creating shared state // and calling the pair provider. Suite documents factory timing, zero values, // shared state, and caller ownership of returned objects. func Command[I, S any](suite Suite[I, S]) commandkit.Command { prepared := captureSuite(suite) if len(prepared.scopes) == 0 { prepared.issues = append(prepared.issues, errors.New("configuration suite must declare at least one named scope")) return commandkit.WithError(commandkit.NoCommand(), errors.Join(prepared.issues...)) } children := make([]commandkit.Command, 0, len(prepared.operations)+1) for _, operation := range prepared.operations { children = append(children, newConfigOperationCommand(prepared, operation)) } children = append(children, newConfigScopesCommand(prepared)) declaration := commandkit.RootGroup("config", "Inspect configuration.", children...) if len(prepared.issues) > 0 { declaration = commandkit.WithError(declaration, errors.Join(prepared.issues...)) } return declaration } // AppCommand builds the state-free, input-free convenience suite for application // configuration. The application consumer supplies only its ConfigKit loader, // which runs for ordinary operations and separately for each reached comparison // side after input preflight. func AppCommand[T any](load func(context.Context, Selection) (*configkit.Result[T], error)) commandkit.Command { var adapterLoad func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[T], error) if load != nil { adapterLoad = func(ctx context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[T], error) { return load(ctx, selection) } } adapted := FromConfigKit[struct{}, struct{}, T]("app", "Application configuration.", adapterLoad) return Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{adapted}}) } func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] { prepared := &preparedSuite[I, S]{ newInput: suite.NewInput, newState: suite.NewState, checkInput: suite.CheckInput, byName: make(map[string]*preparedScope[I, S], len(suite.Scopes)), bindingsByOperation: make(map[Operation][]commandkit.Flag[I]), } selectors := make([]Selector[I], len(suite.Selectors)) for index, selector := range suite.Selectors { selector.Scopes = append([]string(nil), selector.Scopes...) selectors[index] = selector } seenNames := make(map[string]struct{}, len(suite.Scopes)) for _, original := range suite.Scopes { scope := original scope.Operations = normalizeOperations(scope.Operations, &prepared.issues, scope.Name) scope.issues = append([]error(nil), original.issues...) if scope.Name == "" { prepared.issues = append(prepared.issues, errors.New("configuration scope name must not be empty")) } if scope.Name == "all" { prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope name %q is reserved", scope.Name)) } if _, exists := seenNames[scope.Name]; exists { prepared.issues = append(prepared.issues, fmt.Errorf("duplicate configuration scope %q", scope.Name)) } else { seenNames[scope.Name] = struct{}{} } for _, issue := range scope.issues { prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q: %w", scope.Name, issue)) } requiresAcquire, requiresCompare := false, false for _, operation := range scope.Operations { if operation == Diff { requiresCompare = true } else { requiresAcquire = true } } if requiresAcquire && scope.Acquire == nil { prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q declares operations without an acquisition callback", scope.Name)) } if requiresCompare && scope.Compare == nil { prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q declares diff without a comparison callback", scope.Name)) } prepared.scopes = append(prepared.scopes, &preparedScope[I, S]{scope: scope}) } sort.SliceStable(prepared.scopes, func(i, j int) bool { return scopeOrder(prepared.scopes[i].scope.Name) < scopeOrder(prepared.scopes[j].scope.Name) }) names := make([]string, 0, len(prepared.scopes)) for _, scope := range prepared.scopes { prepared.byName[scope.scope.Name] = scope names = append(names, scope.scope.Name) } prepared.scopeChoices = joinNames(names) for index, selector := range selectors { seen := make(map[string]struct{}, len(selector.Scopes)) uniqueScopes := make([]string, 0, len(selector.Scopes)) for _, name := range selector.Scopes { if _, exists := seenNames[name]; !exists { prepared.issues = append(prepared.issues, fmt.Errorf("configuration selector %d references unknown scope %q", index, name)) } if _, exists := seen[name]; exists { continue } seen[name] = struct{}{} uniqueScopes = append(uniqueScopes, name) } selectors[index].Scopes = uniqueScopes if len(selector.Scopes) == 0 { for scopeIndex := range prepared.scopes { if len(prepared.scopes[scopeIndex].scope.Operations) > 0 { prepared.scopes[scopeIndex].bindings = append(prepared.scopes[scopeIndex].bindings, selector.Binding) } } } else { for _, name := range uniqueScopes { if scope, exists := prepared.byName[name]; exists && len(scope.scope.Operations) > 0 { scope.bindings = append(scope.bindings, selector.Binding) } } } } for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} { for _, scope := range prepared.scopes { if containsOperation(scope.scope.Operations, operation) { prepared.operations = append(prepared.operations, operation) break } } for _, selector := range selectors { for _, scope := range prepared.scopes { if containsOperation(scope.scope.Operations, operation) && selectorApplies(selector.Scopes, scope.scope.Name) { prepared.bindingsByOperation[operation] = append(prepared.bindingsByOperation[operation], selector.Binding) break } } } } for _, scope := range prepared.scopes { prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{ Name: scope.scope.Name, Operations: append([]Operation(nil), scope.scope.Operations...), }) } return prepared } func normalizeOperations(operations []Operation, issues *[]error, scope string) []Operation { seen := make(map[Operation]struct{}, len(operations)) for _, operation := range operations { switch operation { case Validate, Show, Sources, Explain, Diff: default: *issues = append(*issues, fmt.Errorf("configuration scope %q declares unknown operation %q", scope, operation)) } seen[operation] = struct{}{} } ordered := make([]Operation, 0, len(seen)) for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} { if _, exists := seen[operation]; exists { ordered = append(ordered, operation) } } return ordered } func scopeOrder(name string) string { switch name { case "app": return "\x00" case "promptkit": return "\x01" default: return "\x02" + name } } func containsOperation(operations []Operation, requested Operation) bool { for _, operation := range operations { if operation == requested { return true } } return false } 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) if len(ordered) == 0 { return "none" } return strings.Join(ordered, ", ") } func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit.Command { formatDefault := "text" spec := commandkit.CommandSpec[scopeInventoryInput]{ Name: "scopes", Summary: "List configuration scopes and supported operations.", Flags: []commandkit.Flag[scopeInventoryInput]{commandkit.ValueFlag( commandkit.FlagMeta{Name: "format", Usage: "Output format: text or yaml."}, &formatDefault, parseConfigString, func(input *scopeInventoryInput, value commandkit.Supplied[string]) { input.Format = value.Value }, )}, CheckInput: func(input scopeInventoryInput) error { return checkConfigFormat(input.Format) }, Run: func(invocation commandkit.Invocation, input scopeInventoryInput) commandkit.Outcome { _ = invocation.Reporter().RenderOutput(func(writer io.Writer) error { var report bytes.Buffer if input.Format == "yaml" { if err := encodeYAML(&report, prepared.inventory); err != nil { return err } } else { _, _ = report.WriteString(formatScopeInventory(prepared.inventory)) } _, err := writer.Write(report.Bytes()) return err }) return commandkit.Success() }, } return commandkit.NewCommand(spec) } type scopeInventoryInput struct{ Format string } type scopeInventoryYAML struct { Scopes []scopeMetadata `yaml:"scopes"` } func formatScopeInventory(inventory scopeInventory) string { var output strings.Builder for _, scope := range inventory.Scopes { output.WriteString(scope.Name) output.WriteString(": ") for index, operation := range scope.Operations { if index > 0 { output.WriteString(", ") } output.WriteString(string(operation)) } output.WriteByte('\n') } return output.String() } func checkConfigFormat(format string) error { if format != "text" && format != "yaml" { return fmt.Errorf("unsupported configuration format %q", format) } return nil } func parseConfigString(value string) (string, error) { return value, nil }