Add ConfigKit comparison acquisition
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
# Implement ConfigKit adoption and configuration comparison
|
# Implement ConfigKit adoption and configuration comparison
|
||||||
|
|
||||||
Status: implementation plan; no implementation stages are complete.
|
Status: implementation in progress; Stages 1–5 are complete.
|
||||||
|
|
||||||
## Scope and authority
|
## Scope and authority
|
||||||
|
|
||||||
Follow the stages in order, starting with Stage 1. Each stage is sized for one
|
Follow the stages in order, starting with the first stage without completion
|
||||||
|
evidence. Each stage is sized for one
|
||||||
Luna implementation assignment. The [feature roadmap](configkit.md) owns the
|
Luna implementation assignment. The [feature roadmap](configkit.md) owns the
|
||||||
selected end state, operator behavior, target API, and scope limits. This document
|
selected end state, operator behavior, target API, and scope limits. This document
|
||||||
owns the steps to reach that state. Read [development guidance](../development.md)
|
owns the steps to reach that state. Read [development guidance](../development.md)
|
||||||
@@ -257,14 +258,19 @@ This is the selected internal coordination for the adapter, not a new policy
|
|||||||
for application-supplied pair callbacks. Tests should protect diagnostic retention
|
for application-supplied pair callbacks. Tests should protect diagnostic retention
|
||||||
and required lifecycle boundaries, not an incidental number of context polls.
|
and required lifecycle boundaries, not an incidental number of context polls.
|
||||||
|
|
||||||
**Completion evidence:** direct adapter tests with real ConfigKit loads for
|
**Completion evidence — complete:** `FromConfigKit` now declares all five
|
||||||
public changes, base-versus-overlay loading, equal effective values,
|
operations and wires its typed comparison callback to sequentially load both
|
||||||
defaults/resolution, and paired-safe concealed changes. Test right failure
|
selected sides with the same state, then return `configkit.Diff` records. The
|
||||||
retaining left warnings, wrapped load-error identity, nil results, and
|
adapter keeps successful and `LoadError` diagnostics in left-to-right order,
|
||||||
cancellation between loads. Exercise a native
|
labels comparison diagnostics by side, checks cancellation at the selected
|
||||||
comparison error with a non-nil uninitialized result and retain available
|
boundaries, and wraps load/native comparison failures with their causes intact.
|
||||||
diagnostics. Do not build a global fake comparator just to force an unreachable
|
Direct tests use real ConfigKit loads for public changes, base-versus-overlay
|
||||||
branch; handler-level provider errors can carry diagnostics from both sides.
|
loading, equal values after defaults and resolution, and safe concealed-change
|
||||||
|
records. They also cover both sides' diagnostics when the right load fails,
|
||||||
|
wrapped `LoadError` identity, nil results, cancellation between loads, and a
|
||||||
|
native Diff error from a non-nil uninitialized result while retaining available
|
||||||
|
diagnostics. Ordinary `Acquire` diagnostics and error identity remain covered
|
||||||
|
and unchanged. `go test -count=1 ./...` passes in ckconfig and ckp.
|
||||||
|
|
||||||
## Stage 6: Implement safe YAML snapshot and semantic-path placement helpers
|
## Stage 6: Implement safe YAML snapshot and semantic-path placement helpers
|
||||||
|
|
||||||
|
|||||||
327
integrations/ckconfig/comparison_adapter_test.go
Normal file
327
integrations/ckconfig/comparison_adapter_test.go
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adapterSettings struct {
|
||||||
|
Greeting string `yaml:"greeting"`
|
||||||
|
Secret string `yaml:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func adapterDefinition() configkit.Definition[adapterSettings] {
|
||||||
|
return configkit.Definition[adapterSettings]{
|
||||||
|
Defaults: func() adapterSettings { return adapterSettings{Greeting: "Hello"} },
|
||||||
|
Validate: func(value *adapterSettings, validation *configkit.Validation) {
|
||||||
|
if value.Greeting == "Hello" {
|
||||||
|
validation.Warning(configkit.Keys("greeting"), "using the default greeting")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Secret: func(path configkit.Path) bool { return path.String() == "/secret" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adapterSettingsLoad(source configkit.Source) (*configkit.Result[adapterSettings], error) {
|
||||||
|
return configkit.Load(source, configkit.Options[adapterSettings]{Definition: adapterDefinition()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparisonSide(selection Selection, greeting string) ComparisonSide[struct{ Label string }] {
|
||||||
|
return ComparisonSide[struct{ Label string }]{
|
||||||
|
Selection: selection,
|
||||||
|
Input: struct{ Label string }{Label: greeting},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromConfigKitCompareReturnsNativeChangesAndLoadsEqualSelections(t *testing.T) {
|
||||||
|
selection := Selection{Config: commandkit.Supplied[string]{Value: "same.yml", Set: true}}
|
||||||
|
var calls []struct {
|
||||||
|
selection Selection
|
||||||
|
input struct{ Label string }
|
||||||
|
state *int
|
||||||
|
}
|
||||||
|
scope := FromConfigKit[struct{ Label string }, *int, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, input struct{ Label string }, state *int) (*configkit.Result[adapterSettings], error) {
|
||||||
|
calls = append(calls, struct {
|
||||||
|
selection Selection
|
||||||
|
input struct{ Label string }
|
||||||
|
state *int
|
||||||
|
}{selected, input, state})
|
||||||
|
*state++
|
||||||
|
return adapterSettingsLoad(configkit.Mapping(input.Label, map[string]any{"greeting": input.Label}))
|
||||||
|
})
|
||||||
|
state := new(int)
|
||||||
|
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{ Label string }]{
|
||||||
|
Left: comparisonSide(selection, "before"),
|
||||||
|
Right: comparisonSide(selection, "after"),
|
||||||
|
}, state)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(calls) != 2 || calls[0].selection != selection || calls[1].selection != selection {
|
||||||
|
t.Fatalf("loader calls = %#v", calls)
|
||||||
|
}
|
||||||
|
if calls[0].input.Label != "before" || calls[1].input.Label != "after" || calls[0].state != state || calls[1].state != state || *state != 2 {
|
||||||
|
t.Fatalf("loader inputs/state = %#v, %#v, state=%d", calls[0], calls[1], *state)
|
||||||
|
}
|
||||||
|
if len(acquired.Value) != 1 || acquired.Value[0].Path.String() != "/greeting" || acquired.Value[0].Left.YAML != "before\n" || acquired.Value[0].Right.YAML != "after\n" {
|
||||||
|
t.Fatalf("native differences = %#v", acquired.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
equal, err := scope.Compare(context.Background(), ComparisonRequest[struct{ Label string }]{
|
||||||
|
Left: comparisonSide(selection, "same"),
|
||||||
|
Right: comparisonSide(selection, "same"),
|
||||||
|
}, state)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(equal.Value) != 0 || len(calls) != 4 {
|
||||||
|
t.Fatalf("equal comparison records/calls = %#v / %d", equal.Value, len(calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromConfigKitCompareIgnoresDefaultAndResolutionHistoryForEqualValues(t *testing.T) {
|
||||||
|
type resolvedSettings struct {
|
||||||
|
Greeting string `yaml:"greeting"`
|
||||||
|
}
|
||||||
|
definition := configkit.Definition[resolvedSettings]{
|
||||||
|
Defaults: func() resolvedSettings { return resolvedSettings{Greeting: "resolved"} },
|
||||||
|
Resolve: func(value *resolvedSettings, resolution *configkit.Resolution) error {
|
||||||
|
if value.Greeting != "resolved" {
|
||||||
|
value.Greeting = "resolved"
|
||||||
|
resolution.Record(configkit.Keys("greeting"), configkit.ChangeNormalization, "canonical greeting")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
left, err := configkit.Load(configkit.NoSource(), configkit.Options[resolvedSettings]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
right, err := configkit.Load(configkit.Mapping("right-source", map[string]any{"greeting": "source value"}), configkit.Options[resolvedSettings]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scope := FromConfigKit[struct{}, struct{}, resolvedSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[resolvedSettings], error) {
|
||||||
|
if selected.Config.Value == "left" {
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
return right, nil
|
||||||
|
})
|
||||||
|
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
|
||||||
|
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||||
|
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||||
|
}, struct{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if left.Config != right.Config || left.Config.Greeting != "resolved" || len(acquired.Value) != 0 {
|
||||||
|
t.Fatalf("equal resolved values yielded differences: left=%#v right=%#v records=%#v", left.Config, right.Config, acquired.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromConfigKitCompareUsesBaseAndOverlaySelections(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
basePath := filepath.Join(dir, "base.yml")
|
||||||
|
overlayPath := filepath.Join(dir, "overlays", "testing.yml")
|
||||||
|
if err := os.WriteFile(basePath, []byte("greeting: Hello\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Dir(overlayPath), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(overlayPath, []byte("greeting: Goodbye\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loader := func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
|
||||||
|
options := configkit.LayeredOptions[adapterSettings]{Definition: adapterDefinition()}
|
||||||
|
if selection.Overlay.Set {
|
||||||
|
options.Overlay = selection.Overlay.Value
|
||||||
|
options.OverlayFilesystem = &configkit.FilesystemContext{Root: dir, BaseDir: dir}
|
||||||
|
}
|
||||||
|
return configkit.LoadLayered(configkit.Input{Source: configkit.File(selection.Config.Value)}, options)
|
||||||
|
}
|
||||||
|
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", loader)
|
||||||
|
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
|
||||||
|
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: basePath, Set: true}}},
|
||||||
|
Right: ComparisonSide[struct{}]{Selection: Selection{
|
||||||
|
Config: commandkit.Supplied[string]{Value: basePath, Set: true},
|
||||||
|
Overlay: commandkit.Supplied[string]{Value: "testing", Set: true},
|
||||||
|
}},
|
||||||
|
}, struct{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(acquired.Value) != 1 || acquired.Value[0].Path.String() != "/greeting" || acquired.Value[0].Left.YAML != "Hello\n" || acquired.Value[0].Right.YAML != "Goodbye\n" {
|
||||||
|
t.Fatalf("base/overlay differences = %#v", acquired.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromConfigKitCompareRetainsSideDiagnosticsAndLoadError(t *testing.T) {
|
||||||
|
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, loadErr := configkit.Load(configkit.Mapping("right-source", map[string]any{"unknown": true}), configkit.Options[adapterSettings]{Definition: adapterDefinition()})
|
||||||
|
if loadErr == nil {
|
||||||
|
t.Fatal("invalid right source unexpectedly loaded")
|
||||||
|
}
|
||||||
|
wrapper := fmt.Errorf("application loader context: %w", loadErr)
|
||||||
|
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
|
||||||
|
if selected.Config.Value == "left" {
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
return nil, wrapper
|
||||||
|
})
|
||||||
|
acquired, gotErr := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
|
||||||
|
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||||
|
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||||
|
}, struct{}{})
|
||||||
|
if !errors.Is(gotErr, loadErr) {
|
||||||
|
t.Fatalf("load error identity not preserved: %v", gotErr)
|
||||||
|
}
|
||||||
|
var typed *configkit.LoadError
|
||||||
|
if !errors.As(gotErr, &typed) || typed != loadErr {
|
||||||
|
t.Fatalf("load error type not preserved: %#v", typed)
|
||||||
|
}
|
||||||
|
if len(acquired.Diagnostics) != 2 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || acquired.Diagnostics[1].Severity != commandkit.SeverityError {
|
||||||
|
t.Fatalf("retained diagnostics = %#v", acquired.Diagnostics)
|
||||||
|
}
|
||||||
|
var leftText, rightText bytes.Buffer
|
||||||
|
if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := acquired.Diagnostics[1].Write(&rightText); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) {
|
||||||
|
t.Fatalf("left diagnostic lost side/source identity: %q", leftText.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rightText.String(), `Configuration scope "app" right:`) || !strings.Contains(rightText.String(), `"right-source"`) {
|
||||||
|
t.Fatalf("right diagnostic lost side/source identity: %q", rightText.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromConfigKitCompareStopsAfterLeftLoadFailure(t *testing.T) {
|
||||||
|
_, loadErr := configkit.Load(configkit.Mapping("left-source", map[string]any{"unknown": true}), configkit.Options[adapterSettings]{Definition: adapterDefinition()})
|
||||||
|
if loadErr == nil {
|
||||||
|
t.Fatal("invalid left source unexpectedly loaded")
|
||||||
|
}
|
||||||
|
rightCalled := false
|
||||||
|
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
|
||||||
|
if selected.Config.Value == "left" {
|
||||||
|
return nil, loadErr
|
||||||
|
}
|
||||||
|
rightCalled = true
|
||||||
|
return adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"greeting": "Goodbye"}))
|
||||||
|
})
|
||||||
|
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
|
||||||
|
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||||
|
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||||
|
}, struct{}{})
|
||||||
|
if !errors.Is(err, loadErr) || rightCalled {
|
||||||
|
t.Fatalf("left failure/right load = %v / %v", err, rightCalled)
|
||||||
|
}
|
||||||
|
var typed *configkit.LoadError
|
||||||
|
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||||
|
t.Fatalf("left failure identity/diagnostics = %v / %#v", err, acquired.Diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromConfigKitCompareCancellationStopsBeforeRightLoad(t *testing.T) {
|
||||||
|
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
rightCalled := false
|
||||||
|
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
|
||||||
|
if selection.Config.Value == "left" {
|
||||||
|
cancel()
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
rightCalled = true
|
||||||
|
return adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"greeting": "Goodbye"}))
|
||||||
|
})
|
||||||
|
acquired, err := scope.Compare(ctx, ComparisonRequest[struct{}]{
|
||||||
|
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||||
|
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||||
|
}, struct{}{})
|
||||||
|
if !errors.Is(err, context.Canceled) || rightCalled {
|
||||||
|
t.Fatalf("cancellation/right load = %v / %v", err, rightCalled)
|
||||||
|
}
|
||||||
|
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||||
|
t.Fatalf("left diagnostics lost on cancellation: %#v", acquired.Diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromConfigKitCompareRejectsNilResultAndRetainsNativeDiffFailureDiagnostics(t *testing.T) {
|
||||||
|
t.Run("nil result", func(t *testing.T) {
|
||||||
|
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
_, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{}, struct{}{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `"app" left`) || !strings.Contains(err.Error(), "nil result") {
|
||||||
|
t.Fatalf("nil left result error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("native diff error", func(t *testing.T) {
|
||||||
|
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
|
||||||
|
if selection.Config.Value == "left" {
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
return &configkit.Result[adapterSettings]{}, nil
|
||||||
|
})
|
||||||
|
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
|
||||||
|
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||||
|
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||||
|
}, struct{}{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "native diff") {
|
||||||
|
t.Fatalf("native diff error = %v", err)
|
||||||
|
}
|
||||||
|
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||||
|
t.Fatalf("native diff failure lost diagnostics: %#v", acquired.Diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromConfigKitComparePreservesConcealedNativeDifference(t *testing.T) {
|
||||||
|
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"secret": "old"}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
right, err := adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"secret": "new"}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
|
||||||
|
if selected.Config.Value == "left" {
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
return right, nil
|
||||||
|
})
|
||||||
|
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
|
||||||
|
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||||
|
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||||
|
}, struct{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(acquired.Value) != 1 || !acquired.Value[0].HiddenChanged || !acquired.Value[0].Left.Redacted || !acquired.Value[0].Right.Redacted || acquired.Value[0].Left.YAML != acquired.Value[0].Right.YAML {
|
||||||
|
t.Fatalf("concealed native difference was altered: %#v", acquired.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromConfigKit adapts a typed ConfigKit loader to all four native
|
// FromConfigKit adapts a typed ConfigKit loader to all five native
|
||||||
// configuration operations. The loader receives the operation's explicit
|
// configuration operations. The loader receives the operation's explicit
|
||||||
// Selection, initialized input, and shared invocation state, and runs only when
|
// Selection, initialized input, and shared invocation state, and runs only when
|
||||||
// an installed operation selects this scope. Construction and help do not call
|
// an installed operation selects this scope. Construction and help do not call
|
||||||
@@ -24,7 +24,7 @@ func FromConfigKit[I, S, T any](
|
|||||||
scope := Scope[I, S]{
|
scope := Scope[I, S]{
|
||||||
Name: name,
|
Name: name,
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
Operations: []Operation{Validate, Show, Sources, Explain},
|
Operations: []Operation{Validate, Show, Sources, Explain, Diff},
|
||||||
}
|
}
|
||||||
if load == nil {
|
if load == nil {
|
||||||
return WithScopeError(scope, fmt.Errorf("ConfigKit scope %q loader must not be nil", name))
|
return WithScopeError(scope, fmt.Errorf("ConfigKit scope %q loader must not be nil", name))
|
||||||
@@ -49,10 +49,71 @@ func FromConfigKit[I, S, T any](
|
|||||||
Diagnostics: configKitDiagnostics(name, sources, result.Diagnostics()),
|
Diagnostics: configKitDiagnostics(name, sources, result.Diagnostics()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
left, leftDiagnostics, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
|
||||||
|
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
|
||||||
|
if err != nil {
|
||||||
|
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
right, rightDiagnostics, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
|
||||||
|
diagnostics = append(diagnostics, rightDiagnostics...)
|
||||||
|
if err != nil {
|
||||||
|
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
differences, err := configkit.Diff(left, right)
|
||||||
|
if err != nil {
|
||||||
|
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
|
||||||
|
}
|
||||||
|
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil
|
||||||
|
}
|
||||||
return scope
|
return scope
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadComparisonSide[I, S, T any](
|
||||||
|
ctx context.Context,
|
||||||
|
scope, side string,
|
||||||
|
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
|
||||||
|
request ComparisonSide[I],
|
||||||
|
state S,
|
||||||
|
) (*configkit.Result[T], []commandkit.Diagnostic, error) {
|
||||||
|
result, err := load(ctx, request.Selection, request.Input, state)
|
||||||
|
if err != nil {
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
var diagnostics []commandkit.Diagnostic
|
||||||
|
if errors.As(err, &loadError) && loadError != nil {
|
||||||
|
diagnostics = configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
return nil, diagnostics, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return nil, nil, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
|
||||||
|
}
|
||||||
|
return result, configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics()), nil
|
||||||
|
}
|
||||||
|
|
||||||
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||||
|
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||||
|
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -72,7 +133,7 @@ func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records
|
|||||||
return []commandkit.Diagnostic{{
|
return []commandkit.Diagnostic{{
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Write: func(writer io.Writer) error {
|
Write: func(writer io.Writer) error {
|
||||||
if err := writeDiagnosticText(writer, "Configuration scope "+strconv.Quote(scope)+":\n"); err != nil {
|
if err := writeDiagnosticText(writer, heading); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(sources) > 0 {
|
if len(sources) > 0 {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
|
|||||||
st.Calls++
|
st.Calls++
|
||||||
return result, nil
|
return result, nil
|
||||||
})
|
})
|
||||||
if len(scope.Operations) != 4 || scope.Operations[0] != Validate || scope.Operations[1] != Show || scope.Operations[2] != Sources || scope.Operations[3] != Explain {
|
if len(scope.Operations) != 5 || scope.Operations[0] != Validate || scope.Operations[1] != Show || scope.Operations[2] != Sources || scope.Operations[3] != Explain || scope.Operations[4] != Diff {
|
||||||
t.Fatalf("operations = %#v", scope.Operations)
|
t.Fatalf("operations = %#v", scope.Operations)
|
||||||
}
|
}
|
||||||
if gotContext != nil {
|
if gotContext != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user