From a37e6895e114e482bd59590cb58cc5ad67822a56 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 9 Oct 2026 12:37:18 +0000 Subject: [PATCH] Add effective diff consumer examples --- docs/roadmap/implementation.md | 11 +- example_test.go | 26 +++++ examples/consumers/diff.go | 136 +++++++++++++++++++++++ examples/consumers/diff_test.go | 187 ++++++++++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 examples/consumers/diff.go create mode 100644 examples/consumers/diff_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index bc64f32..02e87c2 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -352,7 +352,7 @@ and the existing nil-result/`LoadError` load contract. ## Stage 9 — Demonstrate both consumer workflows -Status: planned. Scope: maintained examples and their behavior tests. +Status: complete. Scope: maintained examples and their behavior tests. 1. Add a small complete example under `examples/consumers` that loads two caller- selected files with one definition, handles each load error, calls `Diff`, and @@ -374,6 +374,15 @@ Validate: `go test ./examples/consumers`, `go test . -run '^ExampleDiff$'` when added, and the everyday check. Done: applications can implement the motivating workflows without internal imports or their own structural comparison. +Evidence: `go test ./examples/consumers`, `go test . -run '^ExampleDiff$'`, +and `./scripts/check.sh` passed. Temporary-file coverage exercises both +caller-selected loads and base-versus-selected-overlay comparison. The overlay +case verifies a recorded derived URL after resolution, a retained lower-priority +port, bilateral redaction and `HiddenChanged` for a nested secret-reference +change, deterministic consumer formatting, and no credential disclosure. The +presenter uses snapshot presence and record membership without interpreting the +redaction marker; no full-context rendering supplements the diff records. + ## Stage 10 — Reconcile documentation and audit the complete feature Status: planned. Scope: current-behavior documentation, integration audit, and diff --git a/example_test.go b/example_test.go index 0a05346..abe843a 100644 --- a/example_test.go +++ b/example_test.go @@ -30,6 +30,32 @@ func ExampleNoSource() { // Output: 4 0 } +func ExampleDiff() { + type Config struct { + Workers int `yaml:"workers"` + } + before, err := configkit.Load[Config](configkit.Mapping("before", map[string]any{"workers": 2})) + if err != nil { + fmt.Println(err) + return + } + after, err := configkit.Load[Config](configkit.Mapping("after", map[string]any{"workers": 4})) + if err != nil { + fmt.Println(err) + return + } + differences, err := configkit.Diff(before, after) + if err != nil { + fmt.Println(err) + return + } + for _, difference := range differences { + fmt.Printf("%s: %s -> %s\n", difference.Path.String(), + strings.TrimSpace(difference.Left.YAML), strings.TrimSpace(difference.Right.YAML)) + } + // Output: /workers: 2 -> 4 +} + func ExampleValidation_Error() { type Config struct { First string `yaml:"first"` diff --git a/examples/consumers/diff.go b/examples/consumers/diff.go new file mode 100644 index 0000000..84acd0a --- /dev/null +++ b/examples/consumers/diff.go @@ -0,0 +1,136 @@ +package consumers + +import ( + "fmt" + "strconv" + "strings" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +// FileComparisonConfig is a small application-owned shape for comparing +// caller-selected configuration files. +type FileComparisonConfig struct { + Name string `yaml:"name"` + Entries map[string]any `yaml:"entries"` +} + +// CompareFiles loads two caller-selected files with the same application +// definition, then formats only the safe records returned by configkit.Diff. +func CompareFiles(leftPath, rightPath string) (string, error) { + options := configkit.Options[FileComparisonConfig]{ + Definition: configkit.Definition[FileComparisonConfig]{ + Defaults: func() FileComparisonConfig { + return FileComparisonConfig{Entries: map[string]any{}} + }, + }, + } + left, err := configkit.Load(configkit.File(leftPath), options) + if err != nil { + return "", fmt.Errorf("load left configuration: %w", err) + } + right, err := configkit.Load(configkit.File(rightPath), options) + if err != nil { + return "", fmt.Errorf("load right configuration: %w", err) + } + differences, err := configkit.Diff(left, right) + if err != nil { + return "", err + } + return FormatDifferences(differences), nil +} + +// RolloutConfig models a service endpoint whose URL is derived by the +// application resolver and whose credential reference is classified. +type RolloutConfig struct { + Service RolloutService `yaml:"service"` +} + +// RolloutService contains endpoint settings and a classified credential. +type RolloutService struct { + Endpoint RolloutEndpoint `yaml:"endpoint"` + Credential RolloutCredential `yaml:"credential"` +} + +// RolloutEndpoint is the effective destination used by the application. +type RolloutEndpoint struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + URL string `yaml:"url"` +} + +// RolloutCredential holds only a typed secret reference in configuration. +type RolloutCredential struct { + Token configkit.Secret `yaml:"token" configkit:"secret"` +} + +// LoadRollout loads a base file, optionally with one selected configuration +// overlay. Resolution derives the effective URL after the overlay is applied. +func LoadRollout(basePath, overlayName string) (*configkit.Result[RolloutConfig], error) { + return configkit.Load(configkit.File(basePath), configkit.Options[RolloutConfig]{ + Overlay: overlayName, + Definition: configkit.Definition[RolloutConfig]{ + Resolve: resolveRollout, + }, + }) +} + +// CompareRolloutOverlay compares the effective base configuration with that +// same file plus one selected overlay. It presents only safe diff records. +func CompareRolloutOverlay(basePath, overlayName string) (string, error) { + base, err := LoadRollout(basePath, "") + if err != nil { + return "", fmt.Errorf("load base configuration: %w", err) + } + withOverlay, err := LoadRollout(basePath, overlayName) + if err != nil { + return "", fmt.Errorf("load overlay configuration: %w", err) + } + differences, err := configkit.Diff(base, withOverlay) + if err != nil { + return "", err + } + return FormatDifferences(differences), nil +} + +func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resolution) error { + endpoint := &configuration.Service.Endpoint + if endpoint.Host == "" || endpoint.Port <= 0 { + return nil + } + url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port) + if endpoint.URL != url { + endpoint.URL = url + resolution.Record(configkit.Keys("service", "endpoint", "url"), + configkit.ChangeNormalization, "derived endpoint URL", + configkit.Keys("service", "endpoint", "host"), + configkit.Keys("service", "endpoint", "port")) + } + return nil +} + +// FormatDifferences is consumer-owned presentation. It distinguishes absence +// through Present and reports withheld changes through HiddenChanged without +// interpreting redaction marker text. +func FormatDifferences(differences []configkit.Difference) string { + if len(differences) == 0 { + return "No effective changes.\n" + } + var output strings.Builder + for _, difference := range differences { + fmt.Fprintf(&output, "%s: left=%s right=%s", difference.Path.String(), + formatSnapshot(difference.Left), formatSnapshot(difference.Right)) + if difference.HiddenChanged { + output.WriteString(" hidden-change") + } + output.WriteByte('\n') + } + return output.String() +} + +func formatSnapshot(snapshot configkit.ValueSnapshot) string { + if !snapshot.Present { + return "" + } + return strconv.Quote(snapshot.YAML) +} diff --git a/examples/consumers/diff_test.go b/examples/consumers/diff_test.go new file mode 100644 index 0000000..c45e2be --- /dev/null +++ b/examples/consumers/diff_test.go @@ -0,0 +1,187 @@ +package consumers_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" +) + +func ExampleCompareFiles() { + directory, err := os.MkdirTemp("", "configkit-diff-files-") + if err != nil { + fmt.Println(err) + return + } + defer os.RemoveAll(directory) + leftPath := filepath.Join(directory, "before.yml") + rightPath := filepath.Join(directory, "after.yml") + if err := os.WriteFile(leftPath, []byte("name: before\nentries: {}\n"), 0o600); err != nil { + fmt.Println(err) + return + } + if err := os.WriteFile(rightPath, []byte("name: after\nentries:\n added: null\n"), 0o600); err != nil { + fmt.Println(err) + return + } + output, err := consumers.CompareFiles(leftPath, rightPath) + if err != nil { + fmt.Println(err) + return + } + fmt.Print(output) + // Output: + // /entries/added: left= right="null\n" + // /name: left="before\n" right="after\n" +} + +func ExampleCompareRolloutOverlay() { + const baseToken = "CONFIGKIT_DIFF_EXAMPLE_BASE_TOKEN" + const overlayToken = "CONFIGKIT_DIFF_EXAMPLE_OVERLAY_TOKEN" + defer preserveEnvironment(baseToken)() + defer preserveEnvironment(overlayToken)() + _ = os.Setenv(baseToken, "disposable-example-base-credential") + _ = os.Setenv(overlayToken, "disposable-example-overlay-credential") + + directory, err := os.MkdirTemp("", "configkit-diff-overlay-") + if err != nil { + fmt.Println(err) + return + } + defer os.RemoveAll(directory) + basePath := filepath.Join(directory, "config.yml") + overlayPath := filepath.Join(directory, "overlays", "canary.yml") + if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil { + fmt.Println(err) + return + } + base := "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: " + baseToken + "\n" + overlay := "service:\n endpoint:\n host: canary.example\n credential:\n token: " + overlayToken + "\n" + if err := os.WriteFile(basePath, []byte(base), 0o600); err != nil { + fmt.Println(err) + return + } + if err := os.WriteFile(overlayPath, []byte(overlay), 0o600); err != nil { + fmt.Println(err) + return + } + output, err := consumers.CompareRolloutOverlay(basePath, "canary") + if err != nil { + fmt.Println(err) + return + } + fmt.Print(output) + // Output: + // /service/credential/token: left="'[REDACTED]'\n" right="'[REDACTED]'\n" hidden-change + // /service/endpoint/host: left="base.example\n" right="canary.example\n" + // /service/endpoint/url: left="https://base.example:8443\n" right="https://canary.example:8443\n" +} + +func TestCompareFilesUsesCallerSelectedSourcesAndSafePresence(t *testing.T) { + directory := t.TempDir() + leftPath := filepath.Join(directory, "before.yml") + rightPath := filepath.Join(directory, "after.yml") + writeConfiguration(t, leftPath, "name: before\nentries: {}\n") + writeConfiguration(t, rightPath, "name: after\nentries:\n added: null\n") + + output, err := consumers.CompareFiles(leftPath, rightPath) + if err != nil { + t.Fatal(err) + } + want := "/entries/added: left= right=\"null\\n\"\n" + + "/name: left=\"before\\n\" right=\"after\\n\"\n" + if output != want { + t.Fatalf("comparison output = %q, want %q", output, want) + } + if again, err := consumers.CompareFiles(leftPath, rightPath); err != nil || again != output { + t.Fatalf("repeat output = %q, %v", again, err) + } +} + +func TestCompareFilesReturnsEachLoadError(t *testing.T) { + directory := t.TempDir() + leftPath := filepath.Join(directory, "before.yml") + rightPath := filepath.Join(directory, "after.yml") + writeConfiguration(t, leftPath, "name: before\n") + writeConfiguration(t, rightPath, "unknown: invalid\n") + + output, err := consumers.CompareFiles(leftPath, rightPath) + if err == nil || output != "" || !strings.Contains(err.Error(), "load right configuration") { + t.Fatalf("right load failure = %q, %v", output, err) + } + if _, err := consumers.CompareFiles(filepath.Join(directory, "missing.yml"), rightPath); err == nil || !strings.Contains(err.Error(), "load left configuration") { + t.Fatalf("left load failure = %v", err) + } +} + +func TestCompareRolloutOverlayShowsResolvedSafeChangesAndRetainsSiblings(t *testing.T) { + t.Setenv("CONFIGKIT_DIFF_BASE_TOKEN", "disposable-base-credential") + t.Setenv("CONFIGKIT_DIFF_OVERLAY_TOKEN", "disposable-overlay-credential") + directory := t.TempDir() + basePath := filepath.Join(directory, "config.yml") + overlayPath := filepath.Join(directory, "overlays", "canary.yml") + if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil { + t.Fatal(err) + } + writeConfiguration(t, basePath, "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: CONFIGKIT_DIFF_BASE_TOKEN\n") + writeConfiguration(t, overlayPath, "service:\n endpoint:\n host: canary.example\n credential:\n token: CONFIGKIT_DIFF_OVERLAY_TOKEN\n") + + base, err := consumers.LoadRollout(basePath, "") + if err != nil { + t.Fatal(err) + } + withOverlay, err := consumers.LoadRollout(basePath, "canary") + if err != nil { + t.Fatal(err) + } + if base.Config.Service.Endpoint.Port != 8443 || withOverlay.Config.Service.Endpoint.Port != 8443 { + t.Fatalf("lower-priority port was not retained: base=%#v overlay=%#v", base.Config.Service.Endpoint, withOverlay.Config.Service.Endpoint) + } + urlInfo, found := withOverlay.Metadata().Lookup(configkit.Keys("service", "endpoint", "url")) + if !found || urlInfo.Origin.Layer != configkit.LayerResolution || withOverlay.Config.Service.Endpoint.URL != "https://canary.example:8443" { + t.Fatalf("resolved endpoint = %#v, metadata=%#v, %v", withOverlay.Config.Service.Endpoint, urlInfo, found) + } + + differences, err := configkit.Diff(base, withOverlay) + if err != nil { + t.Fatal(err) + } + if len(differences) != 3 { + t.Fatalf("differences = %#v", differences) + } + wantPaths := []string{"/service/credential/token", "/service/endpoint/host", "/service/endpoint/url"} + for index, difference := range differences { + if got := difference.Path.String(); got != wantPaths[index] { + t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index]) + } + } + secret := differences[0] + if !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged || secret.Left.YAML != "'[REDACTED]'\n" || secret.Right.YAML != "'[REDACTED]'\n" { + t.Fatalf("classified credential difference = %#v", secret) + } + if strings.Contains(consumers.FormatDifferences(differences), "disposable-") { + t.Fatal("formatted differences exposed credential contents") + } + + output, err := consumers.CompareRolloutOverlay(basePath, "canary") + if err != nil || output != consumers.FormatDifferences(differences) { + t.Fatalf("formatted base/overlay comparison = %q, %v", output, err) + } +} + +func TestFormatDifferencesUsesRecordMembershipForNoChange(t *testing.T) { + if got := consumers.FormatDifferences(nil); got != "No effective changes.\n" { + t.Fatalf("no-change output = %q", got) + } +} + +func writeConfiguration(t *testing.T, filename, contents string) { + t.Helper() + if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } +}