From fd8252497724884349fee0515bb0661be313353e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 9 Oct 2026 15:58:04 +0000 Subject: [PATCH] Add captured configuration comparison --- configuration_diff_contract_test.go | 92 ++++++++++++++++++++++++++++ configuration_inspection.go | 14 +++++ docs/roadmap/implementation.md | 6 ++ internal/configuration/inspection.go | 23 ++++++- 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 configuration_diff_contract_test.go diff --git a/configuration_diff_contract_test.go b/configuration_diff_contract_test.go new file mode 100644 index 0000000..b873ee5 --- /dev/null +++ b/configuration_diff_contract_test.go @@ -0,0 +1,92 @@ +package promptkit_test + +import ( + "errors" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/promptkit" +) + +func TestConfigurationInspectionDiffsCapturedValues(t *testing.T) { + left := comparisonInspection(t, "earlier-model") + right := comparisonInspection(t, "later-model") + + differences, err := left.Diff(right) + if err != nil { + t.Fatalf("compare captured configurations: %v", err) + } + if len(differences) != 1 { + t.Fatalf("difference count = %d, want 1: %+v", len(differences), differences) + } + difference := differences[0] + if got, want := difference.Path.String(), "/profiles/offline/model"; got != want { + t.Fatalf("difference path = %q, want %q", got, want) + } + if !strings.Contains(difference.Left.YAML, "earlier-model") || !strings.Contains(difference.Right.YAML, "later-model") { + t.Fatalf("difference snapshots = (%q, %q)", difference.Left.YAML, difference.Right.YAML) + } + + reversed, err := right.Diff(left) + if err != nil { + t.Fatalf("compare reversed configurations: %v", err) + } + if len(reversed) != 1 || reversed[0].Path.String() != difference.Path.String() || + !strings.Contains(reversed[0].Left.YAML, "later-model") || + !strings.Contains(reversed[0].Right.YAML, "earlier-model") { + t.Fatalf("reversed difference = %+v", reversed) + } + + equal := comparisonInspection(t, "earlier-model") + if equal, err := left.Diff(equal); err != nil || len(equal) != 0 { + t.Fatalf("compare equal configurations = (%+v, %v), want no differences", equal, err) + } + if equal, err := left.Diff(left); err != nil || len(equal) != 0 { + t.Fatalf("compare view with itself = (%+v, %v), want no differences", equal, err) + } +} + +func TestConfigurationInspectionDiffRejectsZeroViews(t *testing.T) { + valid := comparisonInspection(t, "current-model") + var zero promptkit.ConfigurationInspection + + for _, comparison := range []struct { + name string + left, right promptkit.ConfigurationInspection + }{ + {name: "zero left", left: zero, right: valid}, + {name: "zero right", left: valid, right: zero}, + {name: "both zero", left: zero, right: zero}, + } { + t.Run(comparison.name, func(t *testing.T) { + differences, err := comparison.left.Diff(comparison.right) + if !errors.Is(err, promptkit.ErrInvalidConfig) || len(differences) != 0 { + t.Fatalf("Diff() = (%+v, %v), want ErrInvalidConfig and no records", differences, err) + } + }) + } +} + +func comparisonInspection(t *testing.T, model string) promptkit.ConfigurationInspection { + t.Helper() + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("comparison fixture", map[string]any{ + "backends": map[string]any{"local": map[string]any{ + "endpoint": "https://example.invalid/v1", + }}, + "profiles": map[string]any{"offline": map[string]any{ + "backend": "local", + "model": model, + }}, + })}}}}, + }}) + if err != nil { + t.Fatalf("construct comparison engine: %v", err) + } + inspection, err := engine.InspectConfiguration() + if err != nil { + t.Fatalf("inspect comparison engine: %v", err) + } + return inspection +} diff --git a/configuration_inspection.go b/configuration_inspection.go index ab65edc..a17830b 100644 --- a/configuration_inspection.go +++ b/configuration_inspection.go @@ -27,6 +27,20 @@ func (e *Engine) InspectConfiguration() (ConfigurationInspection, error) { return ConfigurationInspection{source: e.internal.InspectConfiguration()}, nil } +// Diff compares this view's captured effective configuration with other. This +// view is the earlier (left) value and other is the later (right) value. The +// comparison delegates to ConfigKit's frozen effective-value semantics, so +// source or resolution history alone does not create differences. Either zero +// view returns ErrInvalidConfig and no records. Returned records can contain +// ordinary application data and are not evidence of equivalent execution +// behavior; callers choose where and to whom to disclose them. +func (v ConfigurationInspection) Diff(other ConfigurationInspection) ([]configkit.Difference, error) { + if v.source == nil || other.source == nil { + return nil, ErrInvalidConfig + } + return configuration.Diff(v.source, other.source) +} + // Metadata returns detached effective presence and history records. func (v ConfigurationInspection) Metadata() configkit.Metadata { if v.source == nil { diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 04a66ae..7007176 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -82,6 +82,12 @@ existing validation, and attribution matches the selected source revision. ## Stage 2: Add The Private Bridge And Public Method +**Status: Complete.** Added the private captured-result bridge and public +`ConfigurationInspection.Diff` method. External-package tests cover changed and +equal values, operand direction, self-comparison, and all zero-view pairs. +`GOTOOLCHAIN=local go test . ./internal/configuration ./internal/engine` and +`git diff --check` passed. + Scope: the small integration path and basic facade tests. 1. In `internal/configuration/inspection.go`, add the private wrapper and diff --git a/internal/configuration/inspection.go b/internal/configuration/inspection.go index fd4bf48..b92f0cb 100644 --- a/internal/configuration/inspection.go +++ b/internal/configuration/inspection.go @@ -16,6 +16,15 @@ type Inspection interface { Explain(configkit.Path) (configkit.Explanation, bool) WriteEffective(io.Writer) error WriteExplanation(io.Writer, configkit.Path) error + capturedResult() *configkit.Result[document] +} + +type inspectionResult struct { + *configkit.Result[document] +} + +func (inspection inspectionResult) capturedResult() *configkit.Result[document] { + return inspection.Result } // Inspection returns only the retained result's inspection operations. @@ -23,7 +32,19 @@ func (state *loaded) Inspection() Inspection { if state == nil { return nil } - return state.result + return inspectionResult{Result: state.result} +} + +// Diff compares two captured ConfigKit results without reconstructing them. +func Diff(left, right Inspection) ([]configkit.Difference, error) { + var leftResult, rightResult *configkit.Result[document] + if left != nil { + leftResult = left.capturedResult() + } + if right != nil { + rightResult = right.capturedResult() + } + return configkit.Diff(leftResult, rightResult) } func secretProviderPath(path configkit.Path) bool {