Files

316 lines
11 KiB
Go

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 }