From 8ae542b3a37b88e681c05af11041010211966f56 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 9 Oct 2026 12:34:11 +0000 Subject: [PATCH] Expose effective diffs through the root facade --- diff.go | 53 ++++++++ docs/roadmap/implementation.md | 10 +- facade_diff_test.go | 232 +++++++++++++++++++++++++++++++++ 3 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 diff.go create mode 100644 facade_diff_test.go diff --git a/diff.go b/diff.go new file mode 100644 index 0000000..786a6e3 --- /dev/null +++ b/diff.go @@ -0,0 +1,53 @@ +package configkit + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" + +// Difference describes one canonical changed region between two successful +// loads. Left is the earlier value and Right is the later value. Each snapshot +// is detached; Present distinguishes absence from an explicit null, and +// Redacted reports whether combined sensitivity affected that side. Mappings +// are compared recursively, while sequences are reported as atomic values. +// Records are ordered by canonical JSON pointer. HiddenChanged indicates that +// a value concealed by combined sensitivity changed, without revealing which +// concealed member changed. +type Difference struct { + Path Path + Left ValueSnapshot + Right ValueSnapshot + HiddenChanged bool +} + +// Diff compares the frozen effective values of two successful loads of the +// same configuration type. It does not reread sources, environment variables, +// or rerun defaults or resolvers. Equal values produce no records even when +// their sources, defaults, or resolution histories differ. Credential contents +// are not compared: typed secret references compare by reference name only. +// +// Both results must be non-nil successful loads with inspection state; nil or +// zero results return an error and no records. Load errors are returned by +// Load, so callers must handle those before calling Diff. Snapshots are safe +// for presentation but are not a complete reloadable configuration. +func Diff[T any](left, right *Result[T]) ([]Difference, error) { + var leftInspection, rightInspection *engine.Inspection + if left != nil { + leftInspection = left.inspection + } + if right != nil { + rightInspection = right.inspection + } + + values, err := engine.Diff(leftInspection, rightInspection) + if err != nil { + return nil, err + } + differences := make([]Difference, len(values)) + for index, value := range values { + differences[index] = Difference{ + Path: fromEnginePath(value.Path), + Left: fromEngineValueSnapshot(value.Left), + Right: fromEngineValueSnapshot(value.Right), + HiddenChanged: value.HiddenChanged, + } + } + return differences, nil +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index edaf725..bc64f32 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -317,7 +317,7 @@ value-free serialization errors remain covered. ## Stage 8 — Add the root-owned public facade -Status: planned. Scope: public declarations, conversion, and external tests. +Status: complete. Scope: public declarations, conversion, and external tests. 1. Add root `diff.go` with the exact roadmap record and generic entry point. Validate nil/zero `Result` state without inspecting caller-owned `Config`; @@ -342,6 +342,14 @@ Validate: `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|Dependency and the everyday check. Done: the selected API is available through the stable root facade without weakened ownership or changed load contracts. +Evidence: the focused facade and boundary tests and `./scripts/check.sh` passed. +External-package coverage verifies canonical record order, recursive mapping and +atomic sequence snapshots, absence versus null, nil/zero result errors, +defaults-only and equal-value comparisons across sources and resolvers, frozen +state after caller and record mutation, callbacks only during load, comparison +after temporary source removal, combined secret redaction, credential rotation, +and the existing nil-result/`LoadError` load contract. + ## Stage 9 — Demonstrate both consumer workflows Status: planned. Scope: maintained examples and their behavior tests. diff --git a/facade_diff_test.go b/facade_diff_test.go new file mode 100644 index 0000000..60bd86a --- /dev/null +++ b/facade_diff_test.go @@ -0,0 +1,232 @@ +package configkit_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +type facadeDiffConfig struct { + Value string `yaml:"value"` + Count int `yaml:"count"` + Items []string `yaml:"items"` + Dynamic map[string]any `yaml:"dynamic"` +} + +type facadeDiffSecretConfig struct { + Secret configkit.Secret `yaml:"secret"` +} + +func TestFacadeDiffReturnsDetachedCanonicalRecords(t *testing.T) { + left, err := configkit.Load[facadeDiffConfig](configkit.Bytes("left.yml", []byte("value: before\ncount: 0\nitems: [one, two]\ndynamic: {}\n"))) + if err != nil { + t.Fatalf("load left: %v", err) + } + right, err := configkit.Load[facadeDiffConfig](configkit.Bytes("right.yml", []byte("value: after\ncount: 0\nitems: [one, three]\ndynamic:\n added: null\n"))) + if err != nil { + t.Fatal(err) + } + + differences, err := configkit.Diff(left, right) + if err != nil { + t.Fatal(err) + } + wantPaths := []string{"/dynamic/added", "/items", "/value"} + if len(differences) != len(wantPaths) { + t.Fatalf("differences = %#v", differences) + } + for index, difference := range differences { + if got := difference.Path.String(); got != wantPaths[index] { + t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index]) + } + } + if differences[0].Left.Present || !differences[0].Right.Present || differences[0].Right.YAML != "null\n" { + t.Errorf("null/absence snapshots = %#v / %#v", differences[0].Left, differences[0].Right) + } + if differences[1].Left.YAML != "- one\n- two\n" || differences[1].Right.YAML != "- one\n- three\n" { + t.Errorf("sequence snapshots = %#v / %#v", differences[1].Left, differences[1].Right) + } + + left.Config.Value = "caller mutation" + left.Config.Items[0] = "caller mutation" + right.Config.Dynamic["added"] = "caller mutation" + differences[2].Left.YAML = "record mutation" + differences[2].Path = configkit.Keys("rewritten") + + again, err := configkit.Diff(left, right) + if err != nil { + t.Fatal(err) + } + if again[2].Path.String() != "/value" || again[2].Left.YAML != "before\n" || again[1].Left.YAML != "- one\n- two\n" { + t.Fatalf("repeat diff changed = %#v", again) + } + explanation, ok := left.Explain(configkit.Keys("value")) + if !ok || explanation.Effective.YAML != "before\n" { + t.Fatalf("frozen explanation = %#v, %v", explanation, ok) + } +} + +func TestFacadeDiffRejectsUnavailableResults(t *testing.T) { + type config struct { + Value string `yaml:"value"` + } + loaded, err := configkit.Load[config](configkit.Bytes("valid.yml", []byte("value: valid\n"))) + if err != nil { + t.Fatal(err) + } + var nilResult *configkit.Result[config] + zeroResult := &configkit.Result[config]{Config: config{Value: "must not be inspected"}} + for name, left := range map[string]*configkit.Result[config]{"nil": nilResult, "zero": zeroResult} { + t.Run(name, func(t *testing.T) { + if records, err := configkit.Diff(left, loaded); err == nil || records != nil { + t.Fatalf("Diff = %#v, %v; want nil records and error", records, err) + } + }) + } + if records, err := configkit.Diff(loaded, nilResult); err == nil || records != nil { + t.Fatalf("right nil Diff = %#v, %v", records, err) + } +} + +func TestFacadeDiffIgnoresSourceAndResolutionHistory(t *testing.T) { + type config struct { + Count int `yaml:"count"` + } + defaults := configkit.Options[config]{Definition: configkit.Definition[config]{ + Defaults: func() config { return config{Count: 7} }, + }} + defaulted, err := configkit.Load[config](configkit.NoSource(), defaults) + if err != nil { + t.Fatal(err) + } + explicit, err := configkit.Load[config](configkit.Bytes("explicit.yml", []byte("count: 7\n"))) + if err != nil { + t.Fatal(err) + } + if differences, err := configkit.Diff(defaulted, explicit); err != nil || len(differences) != 0 { + t.Fatalf("default/source difference = %#v, %v", differences, err) + } + + zeroDefault, err := configkit.Load[config](configkit.NoSource(), configkit.Options[config]{Definition: configkit.Definition[config]{Defaults: func() config { return config{} }}}) + if err != nil { + t.Fatal(err) + } + explicitZero, err := configkit.Load[config](configkit.Bytes("explicit-zero.yml", []byte("count: 0\n"))) + if err != nil { + t.Fatal(err) + } + if differences, err := configkit.Diff(zeroDefault, explicitZero); err != nil || len(differences) != 0 { + t.Fatalf("explicit/defaulted zero difference = %#v, %v", differences, err) + } + + resolve := func(value string) configkit.Options[config] { + return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error { + current.Count = len(value) + 8 + resolution.Record(configkit.Keys("count"), configkit.ChangeNormalization, "set effective count") + return nil + }}} + } + resolvedA, err := configkit.Load[config](configkit.Bytes("a.yml", []byte("{}\n")), resolve("a")) + if err != nil { + t.Fatal(err) + } + resolvedB, err := configkit.Load[config](configkit.Bytes("b.yml", []byte("{}\n")), resolve("b")) + if err != nil { + t.Fatal(err) + } + if differences, err := configkit.Diff(resolvedA, resolvedB); err != nil || len(differences) != 0 { + t.Fatalf("equal resolved values = %#v, %v", differences, err) + } +} + +func TestFacadeDiffUsesFrozenFilesAndRunsCallbacksOnlyDuringLoad(t *testing.T) { + type config struct { + Value string `yaml:"value"` + } + calls := 0 + options := configkit.Options[config]{Definition: configkit.Definition[config]{Defaults: func() config { + calls++ + return config{} + }}} + dir := t.TempDir() + paths := []string{filepath.Join(dir, "left.yml"), filepath.Join(dir, "right.yml")} + for index, contents := range []string{"value: left\n", "value: right\n"} { + if err := os.WriteFile(paths[index], []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + } + left, err := configkit.Load[config](configkit.File(paths[0]), options) + if err != nil { + t.Fatal(err) + } + right, err := configkit.Load[config](configkit.File(paths[1]), options) + if err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("defaults calls after loads = %d", calls) + } + for _, path := range paths { + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + } + differences, err := configkit.Diff(left, right) + if err != nil || len(differences) != 1 || differences[0].Path.String() != "/value" { + t.Fatalf("Diff after removing files = %#v, %v", differences, err) + } + if calls != 2 { + t.Fatalf("defaults reran during Diff: %d calls", calls) + } +} + +func TestFacadeDiffCombinesSecretSensitivityAndLoadErrors(t *testing.T) { + t.Setenv("FACADE_DIFF_SECRET", "credential-one") + secretOptions := configkit.Options[facadeDiffSecretConfig]{Definition: configkit.Definition[facadeDiffSecretConfig]{Secret: func(path configkit.Path) bool { + return path.String() == "/secret" + }}} + left, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("left.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions) + if err != nil { + t.Fatal(err) + } + t.Setenv("FACADE_DIFF_SECRET", "credential-two") + right, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("right.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions) + if err != nil { + t.Fatal(err) + } + if differences, err := configkit.Diff(left, right); err != nil || len(differences) != 0 { + t.Fatalf("credential rotation = %#v, %v", differences, err) + } + t.Setenv("OTHER_FACADE_DIFF_SECRET", "other-credential") + otherReference, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("other.yml", []byte("secret: OTHER_FACADE_DIFF_SECRET\n")), secretOptions) + if err != nil { + t.Fatal(err) + } + differences, err := configkit.Diff(left, otherReference) + if err != nil || len(differences) != 1 || !differences[0].HiddenChanged || !differences[0].Left.Redacted || !differences[0].Right.Redacted { + t.Fatalf("reference change = %#v, %v", differences, err) + } + if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "credential") { + t.Fatalf("credential leaked: %#v", differences) + } + + invalidLeft, leftErr := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("invalid-left.yml", []byte("unknown: true\n"))) + if invalidLeft != nil || leftErr == nil { + t.Fatalf("invalid left load = %#v, %v", invalidLeft, leftErr) + } + var loadError *configkit.LoadError + if !errors.As(leftErr, &loadError) { + t.Fatalf("left load error = %T, want *configkit.LoadError", leftErr) + } + invalidRight, rightErr := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("invalid-right.yml", []byte("unknown: true\n"))) + if invalidRight != nil || rightErr == nil { + t.Fatalf("invalid right load = %#v, %v", invalidRight, rightErr) + } + if !errors.As(rightErr, &loadError) { + t.Fatalf("right load error = %T, want *configkit.LoadError", rightErr) + } +}