From 1e1eca4a883e8363454c4b9308d59a9e56d339b0 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 11 Oct 2026 01:30:18 +0000 Subject: [PATCH] Add ConfigKit scope contracts and adapters --- docs/roadmap/implementation.md | 16 +- integrations/ckconfig/config.go | 461 ------------------ integrations/ckconfig/configkit_adapter.go | 101 ++++ integrations/ckconfig/contracts.go | 101 ++++ .../ckconfig/contracts_adapter_test.go | 187 +++++++ 5 files changed, 401 insertions(+), 465 deletions(-) delete mode 100644 integrations/ckconfig/config.go create mode 100644 integrations/ckconfig/configkit_adapter.go create mode 100644 integrations/ckconfig/contracts.go create mode 100644 integrations/ckconfig/contracts_adapter_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index af096be..bfece74 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # 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 assignment. This document authorizes no publication or changes to sibling repositories. @@ -392,9 +392,17 @@ snapshot-isolation guarantee is added. core-owned severity. Preserve diagnostics returned with failures and appropriate warning/error behavior; caller-owned effects remain caller-owned. -**Completion evidence:** adapter metadata without loader calls, native view facets, -nil original loaders, retained scope causes after repeated attachments/narrowing, -diagnostic provenance and warning/error mapping, and public signature ownership. +**Completion evidence — complete:** ckconfig now owns the selected typed +contracts, with ConfigKit paths and records confined to the integration module. +`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 diff --git a/integrations/ckconfig/config.go b/integrations/ckconfig/config.go deleted file mode 100644 index 2836339..0000000 --- a/integrations/ckconfig/config.go +++ /dev/null @@ -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 -} diff --git a/integrations/ckconfig/configkit_adapter.go b/integrations/ckconfig/configkit_adapter.go new file mode 100644 index 0000000..b904f44 --- /dev/null +++ b/integrations/ckconfig/configkit_adapter.go @@ -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 +} diff --git a/integrations/ckconfig/contracts.go b/integrations/ckconfig/contracts.go new file mode 100644 index 0000000..28e80c8 --- /dev/null +++ b/integrations/ckconfig/contracts.go @@ -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 +} diff --git a/integrations/ckconfig/contracts_adapter_test.go b/integrations/ckconfig/contracts_adapter_test.go new file mode 100644 index 0000000..37aa47d --- /dev/null +++ b/integrations/ckconfig/contracts_adapter_test.go @@ -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 }