package commandkit import ( "context" "errors" "fmt" "io" "sort" "strconv" "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") } commands := make([]Command, 0, 3) installed := make([]ConfigOperation, 0, 2) if configOperationDeclared(normalized.Scopes, ConfigValidate) { validate, err := newConfigValidateCommand(normalized) if err != nil { return err } commands = append(commands, validate) installed = append(installed, ConfigValidate) } if configOperationDeclared(normalized.Scopes, ConfigShow) { show, err := newConfigShowCommand(normalized) if err != nil { return err } commands = append(commands, show) installed = append(installed, ConfigShow) } command, err := newConfigScopesCommandWithScopes(normalized.Scopes, installed) if err != nil { return err } commands = append(commands, command) root, err := Group("config", "Inspect configuration.", commands...) if err != nil { return err } if err := app.Register(root); err != nil { return err } app.configRegistered = true return nil } // NewConfigKitScope adapts a ConfigKit loader into a configuration scope. func NewConfigKitScope[C, T any](name, summary string, load func(context.Context, Selection, C) (*configkit.Result[T], error)) (ConfigScope[C], error) { if load == nil { return ConfigScope[C]{}, fmt.Errorf("ConfigKit scope loader must not be nil") } return ConfigScope[C]{ Name: name, Summary: summary, Operations: []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain}, Acquire: func(ctx context.Context, request ConfigRequest, contextValue C) (Acquired[ConfigView], error) { result, err := load(ctx, request.Selection, contextValue) if err != nil { var loadError *configkit.LoadError if errors.As(err, &loadError) { return Acquired[ConfigView]{Diagnostics: ConfigKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err } return Acquired[ConfigView]{}, err } if result == nil { return Acquired[ConfigView]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name) } return Acquired[ConfigView]{ Value: ConfigView{Effective: result, Sources: result, Explanation: result}, Diagnostics: ConfigKitDiagnostics(name, result.Sources(), result.Diagnostics()), }, nil }, }, nil } // ConfigKitDiagnostics groups ConfigKit records with their scope and source legend. func ConfigKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []Diagnostic { if len(records) == 0 { return nil } severity := configkit.SeverityWarning for _, record := range records { if record.Severity == configkit.SeverityError { severity = configkit.SeverityError break } } return []Diagnostic{{ Severity: severity, Write: func(writer io.Writer) error { if err := writeDiagnosticText(writer, "Configuration scope "+strconv.Quote(scope)+":\n"); err != nil { return err } if len(sources) > 0 { if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil { return err } for _, source := range sources { line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n" if err := writeDiagnosticText(writer, line); err != nil { return err } } } return configkit.WriteDiagnostics(writer, records) }, }} } func writeDiagnosticText(writer io.Writer, value string) error { written, err := io.WriteString(writer, value) if err != nil { return err } if written != len(value) { return io.ErrShortWrite } 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 }