322 lines
10 KiB
Go
322 lines
10 KiB
Go
package ckconfig
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
|
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
|
"go.yaml.in/yaml/v4"
|
|
)
|
|
|
|
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 creates separate input for each selected scope, validates
|
|
// those inputs before acquisition, and creates shared state once for that
|
|
// invocation.
|
|
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 only for a selected operation 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))
|
|
}
|
|
if len(scope.Operations) > 0 && scope.Acquire == nil {
|
|
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q declares operations without an acquisition 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} {
|
|
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:
|
|
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} {
|
|
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 {
|
|
var output []byte
|
|
var err error
|
|
if input.Format == "yaml" {
|
|
output, err = yaml.Marshal(prepared.inventory)
|
|
} else {
|
|
output = []byte(formatScopeInventory(prepared.inventory))
|
|
}
|
|
if err != nil {
|
|
return commandkit.Failure(err)
|
|
}
|
|
if err := writeConfigReport(invocation.Streams.Out, output); err != nil {
|
|
return incompleteConfigReport(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 }
|
|
|
|
func writeConfigReport(writer io.Writer, content []byte) error {
|
|
written, err := writer.Write(content)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if written != len(content) {
|
|
return io.ErrShortWrite
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func incompleteConfigReport(err error) commandkit.Outcome {
|
|
outcome := commandkit.Failure(err)
|
|
outcome.ReportIncomplete = true
|
|
outcome.DeliveryError = err
|
|
return outcome
|
|
}
|