diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 51f08d9..a06f2f7 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -124,10 +124,20 @@ ordinary acquisition for a comparison-only scope. when applicable to a comparison-capable scope. Rely on existing core binding validation for collisions with generated flags. -**Completion evidence:** focused installation/discovery checks for comparison-only, -mixed, unsupported, and metadata-only scopes; missing callback and retained nil -loader issues; captured metadata surviving source edits. Execution is wired in -Stages 3, 4, and 8; do not add temporary handlers for intermediate completeness. +**Completion evidence — complete:** `Diff`, `ComparisonSide[I]`, +`ComparisonRequest[I]`, and the optional `Scope.Compare` callback are declared in +ckconfig. Capability validation now requires `Acquire` only for ordinary +operations and `Compare` only for `Diff`; retained adapter issues still reach +installation after capability narrowing. Operation normalization and discovery +preserve the four existing operation order and append `diff`, generating the +family command only when a scope declares comparison. Focused checks cover +comparison-only, mixed, unsupported, and metadata-only scopes; missing provider +wiring; a narrowed ConfigKit adapter retaining its nil-loader issue; and captured +capability metadata and provider references surviving edits to the source +declaration. Construction, installation, discovery, and unsupported-scope +preflight leave factories and providers uninvoked. The ckconfig and dependent +ckp suites pass. Input flags and successful comparison execution remain for +their later owners. ## Stage 3: Add comparison flags, inheritance, and availability preflight diff --git a/integrations/ckconfig/comparison_contract_test.go b/integrations/ckconfig/comparison_contract_test.go new file mode 100644 index 0000000..bb6404c --- /dev/null +++ b/integrations/ckconfig/comparison_contract_test.go @@ -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) + } +} diff --git a/integrations/ckconfig/contracts.go b/integrations/ckconfig/contracts.go index 22e197c..36e843a 100644 --- a/integrations/ckconfig/contracts.go +++ b/integrations/ckconfig/contracts.go @@ -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 } diff --git a/integrations/ckconfig/family.go b/integrations/ckconfig/family.go index f8bec8a..9dbedee 100644 --- a/integrations/ckconfig/family.go +++ b/integrations/ckconfig/family.go @@ -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) }