Add ConfigKit scope contracts and adapters
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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