Add ConfigKit scope contracts and adapters
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Implement the optional integration modules and captured registration API
|
# Implement the optional integration modules and captured registration API
|
||||||
|
|
||||||
Status: implementation in progress. Stages 1 through 7 are complete; subsequent work follows the ordered roadmap.
|
Status: implementation in progress. Stages 1 through 8 are complete; subsequent work follows the ordered roadmap.
|
||||||
Execute the stages in order. Each stage is sized as a separate Luna coding-agent
|
Execute the stages in order. Each stage is sized as a separate Luna coding-agent
|
||||||
assignment. This document authorizes no publication or changes to sibling
|
assignment. This document authorizes no publication or changes to sibling
|
||||||
repositories.
|
repositories.
|
||||||
@@ -392,9 +392,17 @@ snapshot-isolation guarantee is added.
|
|||||||
core-owned severity. Preserve diagnostics returned with failures and appropriate
|
core-owned severity. Preserve diagnostics returned with failures and appropriate
|
||||||
warning/error behavior; caller-owned effects remain caller-owned.
|
warning/error behavior; caller-owned effects remain caller-owned.
|
||||||
|
|
||||||
**Completion evidence:** adapter metadata without loader calls, native view facets,
|
**Completion evidence — complete:** ckconfig now owns the selected typed
|
||||||
nil original loaders, retained scope causes after repeated attachments/narrowing,
|
contracts, with ConfigKit paths and records confined to the integration module.
|
||||||
diagnostic provenance and warning/error mapping, and public signature ownership.
|
`WithScopeError` copies retained causes without mutating the original and keeps
|
||||||
|
wrapping identity through repeated attachment and operation narrowing. The
|
||||||
|
ConfigKit adapter declares all four capabilities without loading during
|
||||||
|
construction, retains original nil-loader issues, passes context/selection/input/
|
||||||
|
state to the loader, and exposes the result's native effective/source/explanation
|
||||||
|
facets directly. Failed ConfigKit loads retain their original error and structured
|
||||||
|
diagnostics; diagnostic severity and source legends use core severity and native
|
||||||
|
ConfigKit writing. Focused adapter tests pass. The complete ckconfig suite remains
|
||||||
|
for the later family and report implementation stages.
|
||||||
|
|
||||||
## Stage 9: Assemble configuration families and prepare selected inputs
|
## Stage 9: Assemble configuration families and prepare selected inputs
|
||||||
|
|
||||||
|
|||||||
@@ -1,461 +0,0 @@
|
|||||||
package ckconfig
|
|
||||||
|
|
||||||
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.
|
|
||||||
// A required binding is required only when a selected scope uses it.
|
|
||||||
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 preparedConfigSuite[C any] struct {
|
|
||||||
suite ConfigSuite[C]
|
|
||||||
ordered []ConfigScope[C]
|
|
||||||
byName map[string]ConfigScope[C]
|
|
||||||
installed []ConfigOperation
|
|
||||||
inventory scopesInventory
|
|
||||||
}
|
|
||||||
|
|
||||||
type configScopesInput struct {
|
|
||||||
Format string
|
|
||||||
}
|
|
||||||
|
|
||||||
type selectorState[C any] struct {
|
|
||||||
name string
|
|
||||||
required bool
|
|
||||||
scopes []string
|
|
||||||
supplied bool
|
|
||||||
apply func(*C)
|
|
||||||
clear func(*C)
|
|
||||||
}
|
|
||||||
|
|
||||||
type configInput[C any] struct {
|
|
||||||
Selection Selection
|
|
||||||
Scope string
|
|
||||||
Format string
|
|
||||||
Path Supplied[string]
|
|
||||||
SelectorContext C
|
|
||||||
selectors []selectorState[C]
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterConfig installs the available config handlers and the scope discovery
|
|
||||||
// command. Its registration and discovery paths do not invoke scope loaders.
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
prepared := prepareConfigSuite(normalized)
|
|
||||||
|
|
||||||
commands := make([]Command, 0, 5)
|
|
||||||
for _, operation := range prepared.installed {
|
|
||||||
var command Command
|
|
||||||
switch operation {
|
|
||||||
case ConfigValidate:
|
|
||||||
command, err = newConfigValidateCommand(prepared)
|
|
||||||
case ConfigShow:
|
|
||||||
command, err = newConfigShowCommand(prepared)
|
|
||||||
case ConfigSources:
|
|
||||||
command, err = newConfigSourcesCommand(prepared)
|
|
||||||
case ConfigExplain:
|
|
||||||
command, err = newConfigExplainCommand(prepared)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
commands = append(commands, command)
|
|
||||||
}
|
|
||||||
command, err := newConfigScopesCommandWithPrepared(prepared)
|
|
||||||
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 scope with validation,
|
|
||||||
// effective display, source listing, and path explanation capabilities. The
|
|
||||||
// loader runs once per selected operation. Successful loads expose the result's
|
|
||||||
// public facets directly; ConfigKit LoadError records become CommandKit
|
|
||||||
// diagnostics while the original error is returned.
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
seenNames := make(map[string]struct{}, len(normalized.Selectors))
|
|
||||||
seenShorts := make(map[string]struct{}, len(normalized.Selectors))
|
|
||||||
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...)
|
|
||||||
if _, exists := seenNames[binding.meta.Name]; exists {
|
|
||||||
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration selector flag --%s", binding.meta.Name)
|
|
||||||
}
|
|
||||||
if binding.meta.Short != "" {
|
|
||||||
if _, exists := seenShorts[binding.meta.Short]; exists {
|
|
||||||
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration selector flag --%s", binding.meta.Name)
|
|
||||||
}
|
|
||||||
seenShorts[binding.meta.Short] = struct{}{}
|
|
||||||
}
|
|
||||||
seenNames[binding.meta.Name] = struct{}{}
|
|
||||||
}
|
|
||||||
return normalized, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareConfigSuite[C any](suite ConfigSuite[C]) *preparedConfigSuite[C] {
|
|
||||||
prepared := &preparedConfigSuite[C]{
|
|
||||||
suite: suite,
|
|
||||||
ordered: orderedConfigScopes(suite.Scopes),
|
|
||||||
byName: make(map[string]ConfigScope[C], len(suite.Scopes)),
|
|
||||||
installed: make([]ConfigOperation, 0, 4),
|
|
||||||
inventory: scopesInventory{Scopes: make([]configScopeInfo, 0, len(suite.Scopes))},
|
|
||||||
}
|
|
||||||
for _, scope := range suite.Scopes {
|
|
||||||
prepared.byName[scope.Name] = scope
|
|
||||||
}
|
|
||||||
for _, operation := range []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain} {
|
|
||||||
if configOperationDeclared(suite.Scopes, operation) {
|
|
||||||
prepared.installed = append(prepared.installed, operation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, scope := range prepared.ordered {
|
|
||||||
operations := make([]ConfigOperation, 0, len(scope.Operations))
|
|
||||||
for _, operation := range scope.Operations {
|
|
||||||
if containsOperation(prepared.installed, operation) {
|
|
||||||
operations = append(operations, operation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prepared.inventory.Scopes = append(prepared.inventory.Scopes, configScopeInfo{Name: scope.Name, Operations: operations})
|
|
||||||
}
|
|
||||||
return prepared
|
|
||||||
}
|
|
||||||
|
|
||||||
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 newConfigScopesCommandWithPrepared[C any](prepared *preparedConfigSuite[C]) (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 {
|
|
||||||
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 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
|
|
||||||
}
|
|
||||||
101
integrations/ckconfig/configkit_adapter.go
Normal file
101
integrations/ckconfig/configkit_adapter.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FromConfigKit adapts a typed ConfigKit loader to all four native
|
||||||
|
// configuration operations. It does not invoke load during construction. A
|
||||||
|
// nil original loader is retained as a construction issue, even if callers
|
||||||
|
// later narrow the scope's operation list.
|
||||||
|
func FromConfigKit[I, S, T any](
|
||||||
|
name, summary string,
|
||||||
|
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
|
||||||
|
) Scope[I, S] {
|
||||||
|
scope := Scope[I, S]{
|
||||||
|
Name: name,
|
||||||
|
Summary: summary,
|
||||||
|
Operations: []Operation{Validate, Show, Sources, Explain},
|
||||||
|
}
|
||||||
|
if load == nil {
|
||||||
|
return WithScopeError(scope, fmt.Errorf("ConfigKit scope %q loader must not be nil", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) {
|
||||||
|
result, err := load(ctx, request.Selection, input, state)
|
||||||
|
if err != nil {
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if errors.As(err, &loadError) && loadError != nil {
|
||||||
|
return commandkit.Acquired[View]{Diagnostics: configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err
|
||||||
|
}
|
||||||
|
return commandkit.Acquired[View]{}, err
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
sources := result.Sources()
|
||||||
|
return commandkit.Acquired[View]{
|
||||||
|
Value: View{Effective: result, Sources: result, Explanation: result},
|
||||||
|
Diagnostics: configKitDiagnostics(name, sources, result.Diagnostics()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
|
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
severity := commandkit.SeverityWarning
|
||||||
|
for _, record := range records {
|
||||||
|
if record.Severity == configkit.SeverityError {
|
||||||
|
severity = commandkit.SeverityError
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the upstream snapshots so later calls to Write do not depend on
|
||||||
|
// slices returned by the provider.
|
||||||
|
sources = append([]configkit.SourceInfo(nil), sources...)
|
||||||
|
records = append([]configkit.Diagnostic(nil), records...)
|
||||||
|
return []commandkit.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
|
||||||
|
}
|
||||||
101
integrations/ckconfig/contracts.go
Normal file
101
integrations/ckconfig/contracts.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Selection carries the configuration and overlay choices supplied for a
|
||||||
|
// configuration scope.
|
||||||
|
type Selection struct {
|
||||||
|
Config commandkit.Supplied[string]
|
||||||
|
Overlay commandkit.Supplied[string]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operation identifies a supported configuration inspection operation.
|
||||||
|
type Operation string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Validate checks the selected configuration and reports diagnostics.
|
||||||
|
Validate Operation = "validate"
|
||||||
|
// Show writes the selected configuration's effective form.
|
||||||
|
Show Operation = "show"
|
||||||
|
// Sources reports the selected configuration's input sources.
|
||||||
|
Sources Operation = "sources"
|
||||||
|
// Explain reports the history and provenance of a selected path.
|
||||||
|
Explain Operation = "explain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request describes one configuration operation and its selected inputs.
|
||||||
|
type Request struct {
|
||||||
|
Operation Operation
|
||||||
|
Selection Selection
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveWriter writes a configuration's native effective representation.
|
||||||
|
type EffectiveWriter interface {
|
||||||
|
WriteEffective(io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceReader reports the native source inventory for a configuration.
|
||||||
|
type SourceReader interface {
|
||||||
|
Sources() []configkit.SourceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExplanationReader reads and writes a configuration's native path explanation.
|
||||||
|
type ExplanationReader interface {
|
||||||
|
Explain(configkit.Path) (configkit.Explanation, bool)
|
||||||
|
WriteExplanation(io.Writer, configkit.Path) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// View exposes native configuration facets without reconstructing their
|
||||||
|
// reports.
|
||||||
|
type View struct {
|
||||||
|
Effective EffectiveWriter
|
||||||
|
Sources SourceReader
|
||||||
|
Explanation ExplanationReader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope declares the operations and acquisition callback for one named
|
||||||
|
// configuration. Construction issues are retained privately until family
|
||||||
|
// assembly attaches them to its command declaration.
|
||||||
|
type Scope[I, S any] struct {
|
||||||
|
Name string
|
||||||
|
Summary string
|
||||||
|
Operations []Operation
|
||||||
|
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
|
||||||
|
|
||||||
|
issues []error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selector binds a typed flag to the configuration scopes that use it.
|
||||||
|
type Selector[I any] struct {
|
||||||
|
Binding commandkit.Flag[I]
|
||||||
|
Scopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suite declares the typed input, shared state, selectors, and configuration
|
||||||
|
// scopes used to assemble configuration commands.
|
||||||
|
type Suite[I, S any] struct {
|
||||||
|
NewInput func() I
|
||||||
|
NewState func() S
|
||||||
|
Selectors []Selector[I]
|
||||||
|
CheckInput func(scope string, selection Selection, input I) error
|
||||||
|
Scopes []Scope[I, S]
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithScopeError returns a copy of scope that retains err as a construction
|
||||||
|
// issue. Existing issues remain available to family assembly, and scope is not
|
||||||
|
// mutated. A nil error returns scope unchanged.
|
||||||
|
func WithScopeError[I, S any](scope Scope[I, S], err error) Scope[I, S] {
|
||||||
|
if err == nil {
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
issues := make([]error, len(scope.issues), len(scope.issues)+1)
|
||||||
|
copy(issues, scope.issues)
|
||||||
|
scope.issues = append(issues, err)
|
||||||
|
return scope
|
||||||
|
}
|
||||||
187
integrations/ckconfig/contracts_adapter_test.go
Normal file
187
integrations/ckconfig/contracts_adapter_test.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWithScopeErrorCopiesAndRetainsCauses(t *testing.T) {
|
||||||
|
first := &scopeCause{message: "first construction problem"}
|
||||||
|
second := errors.New("second construction problem")
|
||||||
|
original := Scope[struct{}, struct{}]{issues: []error{fmt.Errorf("wrapped: %w", first)}}
|
||||||
|
|
||||||
|
copyWithError := WithScopeError(original, second)
|
||||||
|
if len(original.issues) != 1 || !errors.Is(original.issues[0], first) {
|
||||||
|
t.Fatalf("original issues changed: %#v", original.issues)
|
||||||
|
}
|
||||||
|
if len(copyWithError.issues) != 2 || !errors.Is(copyWithError.issues[0], first) || !errors.Is(copyWithError.issues[1], second) {
|
||||||
|
t.Fatalf("attached issues = %#v", copyWithError.issues)
|
||||||
|
}
|
||||||
|
if copyWithError.issues[0] != original.issues[0] {
|
||||||
|
t.Fatal("existing cause identity was not preserved")
|
||||||
|
}
|
||||||
|
var retained *scopeCause
|
||||||
|
if !errors.As(copyWithError.issues[0], &retained) || retained != first {
|
||||||
|
t.Fatalf("retained concrete cause = %#v, want %#v", retained, first)
|
||||||
|
}
|
||||||
|
|
||||||
|
narrowed := copyWithError
|
||||||
|
narrowed.Operations = nil
|
||||||
|
third := errors.New("third construction problem")
|
||||||
|
withThird := WithScopeError(narrowed, third)
|
||||||
|
unchanged := WithScopeError(withThird, nil)
|
||||||
|
if len(unchanged.issues) != len(copyWithError.issues)+1 || !errors.Is(unchanged.issues[2], third) {
|
||||||
|
t.Fatalf("repeated attachment lost issues: %#v", unchanged.issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type scopeCause struct{ message string }
|
||||||
|
|
||||||
|
func (e *scopeCause) Error() string { return e.message }
|
||||||
|
|
||||||
|
func TestFromConfigKitRetainsNilLoaderIssue(t *testing.T) {
|
||||||
|
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "Application configuration", nil)
|
||||||
|
scope.Operations = nil
|
||||||
|
if scope.Acquire != nil {
|
||||||
|
t.Fatal("nil loader produced an acquisition callback")
|
||||||
|
}
|
||||||
|
if len(scope.issues) != 1 || !strings.Contains(scope.issues[0].Error(), "loader") {
|
||||||
|
t.Fatalf("retained nil-loader issues = %#v", scope.issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
|
||||||
|
type input struct{ Label string }
|
||||||
|
type state struct{ Calls int }
|
||||||
|
type settings struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
selection := Selection{
|
||||||
|
Config: commandkit.Supplied[string]{Value: "chosen.yaml", Set: true},
|
||||||
|
Overlay: commandkit.Supplied[string]{Value: "", Set: true},
|
||||||
|
}
|
||||||
|
gotContext, gotSelection, gotInput, gotState := context.Context(nil), Selection{}, input{}, (*state)(nil)
|
||||||
|
result, err := configkit.Load(configkit.Mapping("source.yaml", map[string]any{"name": "native"}), configkit.Options[settings]{Definition: configkit.Definition[settings]{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scope := FromConfigKit[input, *state, settings]("app", "Application configuration", func(got context.Context, selected Selection, in input, st *state) (*configkit.Result[settings], error) {
|
||||||
|
gotContext, gotSelection, gotInput, gotState = got, selected, in, st
|
||||||
|
st.Calls++
|
||||||
|
return result, nil
|
||||||
|
})
|
||||||
|
if len(scope.Operations) != 4 || scope.Operations[0] != Validate || scope.Operations[1] != Show || scope.Operations[2] != Sources || scope.Operations[3] != Explain {
|
||||||
|
t.Fatalf("operations = %#v", scope.Operations)
|
||||||
|
}
|
||||||
|
if gotContext != nil {
|
||||||
|
t.Fatal("loader ran during adapter construction")
|
||||||
|
}
|
||||||
|
stateValue := &state{}
|
||||||
|
acquired, err := scope.Acquire(ctx, Request{Operation: Show, Selection: selection}, input{Label: "typed"}, stateValue)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotContext != ctx || gotSelection != selection || gotInput.Label != "typed" || gotState != stateValue || stateValue.Calls != 1 {
|
||||||
|
t.Fatalf("loader arguments/context/state = %v, %#v, %#v, %p, %d", gotContext, gotSelection, gotInput, gotState, stateValue.Calls)
|
||||||
|
}
|
||||||
|
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result {
|
||||||
|
t.Fatal("adapter did not expose the native result facets directly")
|
||||||
|
}
|
||||||
|
|
||||||
|
var effective bytes.Buffer
|
||||||
|
if err := acquired.Value.Effective.WriteEffective(&effective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(effective.String(), "name: native") {
|
||||||
|
t.Fatalf("native effective output = %q", effective.String())
|
||||||
|
}
|
||||||
|
sources := acquired.Value.Sources.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].Label != "source.yaml" {
|
||||||
|
t.Fatalf("native sources = %#v", sources)
|
||||||
|
}
|
||||||
|
path, err := configkit.ParsePath("/name")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := acquired.Value.Explanation.Explain(path); !ok {
|
||||||
|
t.Fatal("native explanation facet did not resolve /name")
|
||||||
|
}
|
||||||
|
var explanation bytes.Buffer
|
||||||
|
if err := acquired.Value.Explanation.WriteExplanation(&explanation, path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if explanation.Len() == 0 {
|
||||||
|
t.Fatal("native explanation writer produced no output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
||||||
|
type settings struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
_, loadErr := configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[settings]{Definition: configkit.Definition[settings]{}})
|
||||||
|
if loadErr == nil {
|
||||||
|
t.Fatal("fixture did not produce a ConfigKit load error")
|
||||||
|
}
|
||||||
|
scope := FromConfigKit[struct{}, struct{}, settings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[settings], error) {
|
||||||
|
return nil, loadErr
|
||||||
|
})
|
||||||
|
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||||
|
if gotErr != loadErr {
|
||||||
|
t.Fatalf("load error identity = %v, want original %v", gotErr, loadErr)
|
||||||
|
}
|
||||||
|
var typed *configkit.LoadError
|
||||||
|
if !errors.As(gotErr, &typed) {
|
||||||
|
t.Fatalf("load error type was not retained: %T", gotErr)
|
||||||
|
}
|
||||||
|
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||||
|
t.Fatalf("failure diagnostics = %#v", acquired.Diagnostics)
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if err := acquired.Diagnostics[0].Write(&rendered); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) {
|
||||||
|
t.Fatalf("diagnostic output lacks scope/source legend: %q", rendered.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
|
||||||
|
warning := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
|
||||||
|
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
|
||||||
|
t.Fatalf("warning diagnostics = %#v", warning)
|
||||||
|
}
|
||||||
|
withError := configKitDiagnostics("app", nil, []configkit.Diagnostic{
|
||||||
|
{Severity: configkit.SeverityWarning, Message: "safe warning"},
|
||||||
|
{Severity: configkit.SeverityError, Message: "safe error"},
|
||||||
|
})
|
||||||
|
if withError[0].Severity != commandkit.SeverityError {
|
||||||
|
t.Fatalf("mixed diagnostics severity = %q", withError[0].Severity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := withError[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||||
|
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
|
||||||
|
}
|
||||||
|
wantErr := errors.New("writer failed")
|
||||||
|
if err := withError[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("writer error = %v, want %v", err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type shortWriter struct{}
|
||||||
|
|
||||||
|
func (shortWriter) Write(value []byte) (int, error) { return len(value) - 1, nil }
|
||||||
|
|
||||||
|
type failingWriter struct{ err error }
|
||||||
|
|
||||||
|
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||||
Reference in New Issue
Block a user