Add configuration comparison capability contract

This commit is contained in:
2026-10-11 21:36:34 +00:00
parent c883c64be9
commit 018f90a23c
4 changed files with 164 additions and 12 deletions

View File

@@ -0,0 +1,105 @@
package ckconfig
import (
"context"
"errors"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestComparisonCapabilitiesAreIndependentAndCaptured(t *testing.T) {
type input struct{ Value string }
var inputCalls, stateCalls, acquireCalls, compareCalls int
compare := func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
compareCalls++
return commandkit.Acquired[[]configkit.Difference]{}, nil
}
operations := []Operation{Diff}
suite := Suite[input, struct{}]{
NewInput: func() input { inputCalls++; return input{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Scopes: []Scope[input, struct{}]{
{Name: "app", Operations: operations, Compare: compare},
{Name: "promptkit", Operations: []Operation{Diff, Show, Validate}, Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}, Compare: compare},
{Name: "alpha"},
{Name: "plain", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}},
},
}
captured := captureSuite(suite)
declaration := Command(suite)
// Capability metadata and callback references are copied during capture.
suite.Scopes[0].Operations[0] = Show
suite.Scopes[0].Compare = nil
app := newConfigTestApp(t, declaration)
var output strings.Builder
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("scope discovery outcome = %#v", outcome)
}
if got, want := output.String(), "app: diff\npromptkit: validate, show, diff\nalpha: \nplain: validate\n"; got != want {
t.Fatalf("scope discovery = %q, want %q", got, want)
}
// A family-wide diff command is available, but unsupported scopes fail before
// suite factories or either provider runs.
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "plain"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage {
t.Fatalf("unsupported scope outcome = %#v", outcome)
}
if inputCalls != 0 || stateCalls != 0 || acquireCalls != 0 || compareCalls != 0 {
t.Fatalf("discovery or preflight invoked suite work: input=%d state=%d acquire=%d compare=%d", inputCalls, stateCalls, acquireCalls, compareCalls)
}
if captured.byName["app"].scope.Compare == nil {
t.Fatal("captured comparison callback was cleared by a source edit")
}
if _, err := captured.byName["app"].scope.Compare(context.Background(), ComparisonRequest[input]{}, struct{}{}); err != nil || compareCalls != 1 {
t.Fatalf("captured comparison callback result=%v calls=%d", err, compareCalls)
}
withoutComparison := newConfigTestApp(t, Command(Suite[input, struct{}]{Scopes: []Scope[input, struct{}]{{
Name: "plain", Operations: []Operation{Validate},
Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{}, nil
},
}}}))
outcome = withoutComparison.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage {
t.Fatalf("family without comparison exposed diff: %#v", outcome)
}
}
func TestComparisonCallbackWiringAndRetainedAdapterIssues(t *testing.T) {
newApp := func(scope Scope[struct{}, struct{}]) error {
_, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
return err
}
if err := newApp(Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Diff}}); err == nil {
t.Fatalf("missing comparison callback error = %v", err)
}
if err := newApp(Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Validate}}); err == nil {
t.Fatalf("missing ordinary acquisition callback error = %v", err)
}
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "Application configuration", nil)
scope.Operations = []Operation{Diff}
scope.Compare = func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
}
retained := scope.issues[0]
if err := newApp(scope); err == nil || !errors.Is(err, retained) {
t.Fatalf("narrowed adapter lost its retained loader issue: %v", err)
}
}

View File

@@ -29,6 +29,8 @@ const (
Sources Operation = "sources"
// Explain reports the history and provenance of a selected path.
Explain Operation = "explain"
// Diff compares two selections of the same configuration scope.
Diff Operation = "diff"
)
// Request describes one configuration operation and its selected inputs.
@@ -39,6 +41,24 @@ type Request struct {
Selection Selection
}
// ComparisonSide groups one configuration selection with its independently
// prepared typed input.
type ComparisonSide[I any] struct {
// Selection carries the explicit config and overlay inputs for this side.
Selection Selection
// Input is initialized and checked for this side before comparison.
Input I
}
// ComparisonRequest carries the earlier and later selections supplied to a
// scope's comparison callback.
type ComparisonRequest[I any] struct {
// Left is the earlier configuration selection.
Left ComparisonSide[I]
// Right is the later configuration selection.
Right ComparisonSide[I]
}
// EffectiveWriter writes a configuration's native effective representation.
type EffectiveWriter interface {
WriteEffective(io.Writer) error
@@ -63,9 +83,11 @@ type View struct {
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.
// Scope declares the operations and providers for one named configuration.
// Ordinary inspection operations use Acquire; Diff uses Compare and does not
// require Acquire unless an ordinary operation is also declared. Construction
// issues are retained privately until family assembly attaches them to its
// command declaration.
type Scope[I, S any] struct {
// Name identifies the scope in CLI selection and reports.
Name string
@@ -73,8 +95,12 @@ type Scope[I, S any] struct {
Summary string
// Operations selects the standard handlers exposed for this scope.
Operations []Operation
// Acquire loads the selected configuration for one inspection invocation.
// Acquire loads the selected configuration for one ordinary inspection
// invocation.
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
// Compare compares two selections for this scope using one shared state.
// It is required when Diff is declared in Operations.
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
issues []error
}

View File

@@ -112,9 +112,20 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
for _, issue := range scope.issues {
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q: %w", scope.Name, issue))
}
if len(scope.Operations) > 0 && scope.Acquire == nil {
requiresAcquire, requiresCompare := false, false
for _, operation := range scope.Operations {
if operation == Diff {
requiresCompare = true
} else {
requiresAcquire = true
}
}
if requiresAcquire && scope.Acquire == nil {
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q declares operations without an acquisition callback", scope.Name))
}
if requiresCompare && scope.Compare == nil {
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q declares diff without a comparison callback", scope.Name))
}
prepared.scopes = append(prepared.scopes, &preparedScope[I, S]{scope: scope})
}
sort.SliceStable(prepared.scopes, func(i, j int) bool {
@@ -156,7 +167,7 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
}
}
for _, operation := range []Operation{Validate, Show, Sources, Explain} {
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
for _, scope := range prepared.scopes {
if containsOperation(scope.scope.Operations, operation) {
prepared.operations = append(prepared.operations, operation)
@@ -185,14 +196,14 @@ func normalizeOperations(operations []Operation, issues *[]error, scope string)
seen := make(map[Operation]struct{}, len(operations))
for _, operation := range operations {
switch operation {
case Validate, Show, Sources, Explain:
case Validate, Show, Sources, Explain, Diff:
default:
*issues = append(*issues, fmt.Errorf("configuration scope %q declares unknown operation %q", scope, operation))
}
seen[operation] = struct{}{}
}
ordered := make([]Operation, 0, len(seen))
for _, operation := range []Operation{Validate, Show, Sources, Explain} {
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
if _, exists := seen[operation]; exists {
ordered = append(ordered, operation)
}