diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 50bb345..2e6b8c8 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement ConfigKit adoption and configuration comparison -Status: implementation in progress; Stages 1–8 are complete. +Status: implementation in progress; Stages 1–9 are complete. ## Scope and authority @@ -445,9 +445,15 @@ without custom comparison wiring. go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml ``` -**Completion evidence:** the complete consumer compiles in ckconfig's suite; -fixture comparison and unchanged default commands run without external services. -Composed state/cache and PromptKit changes belong only to Stage 10. +**Completion evidence — complete:** the simple consumer now uses ConfigKit file +sources when `--config` is supplied, preserves the in-memory default when it is +omitted, and forwards supplied overlays through ConfigKit options. The required +before/after fixtures and README invocation are present. Its assembled-app test +checks `changes.greeting: Goodbye`, equal fixture selections with empty +categories, default `config show`, default `greet`, and explicit empty config +selection failing instead of falling back. The documented diff, show, and greet +commands also run locally without external services; the ckconfig suite passes. +Composed state/cache and PromptKit changes remain for the following stage. ## Stage 10: Demonstrate composed selection and typed-input cache identity diff --git a/integrations/ckconfig/examples/simple/README.md b/integrations/ckconfig/examples/simple/README.md index 602c382..0b67d70 100644 --- a/integrations/ckconfig/examples/simple/README.md +++ b/integrations/ckconfig/examples/simple/README.md @@ -4,6 +4,7 @@ Run the maintained example with: ```sh go run . config show +go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml go run . greet go run . greet --count=0 --tag=first --tag second ``` @@ -11,4 +12,6 @@ go run . greet --count=0 --tag=first --tag second The `config` family comes from one typed ConfigKit scope. The custom `greet` command uses CommandKit's typed bindings and shows the difference between an omitted `--count` and an explicitly supplied zero, plus ordered repeated tags. -The configuration is synthetic and stays in memory. +With no `--config`, application settings use the in-memory default. A supplied +configuration and overlay are passed to ConfigKit unchanged. The comparison +command loads the two local fixtures and reports `changes.greeting: Goodbye`. diff --git a/integrations/ckconfig/examples/simple/fixtures/after.yml b/integrations/ckconfig/examples/simple/fixtures/after.yml new file mode 100644 index 0000000..9bb850c --- /dev/null +++ b/integrations/ckconfig/examples/simple/fixtures/after.yml @@ -0,0 +1 @@ +greeting: Goodbye diff --git a/integrations/ckconfig/examples/simple/fixtures/before.yml b/integrations/ckconfig/examples/simple/fixtures/before.yml new file mode 100644 index 0000000..0f3a310 --- /dev/null +++ b/integrations/ckconfig/examples/simple/fixtures/before.yml @@ -0,0 +1 @@ +greeting: Hello diff --git a/integrations/ckconfig/examples/simple/main.go b/integrations/ckconfig/examples/simple/main.go index df92c10..a13a0b4 100644 --- a/integrations/ckconfig/examples/simple/main.go +++ b/integrations/ckconfig/examples/simple/main.go @@ -20,10 +20,19 @@ type greetInput struct { Tags commandkit.Supplied[[]string] } -func loadAppConfiguration(_ context.Context, _ ckconfig.Selection) (*configkit.Result[appConfiguration], error) { - return configkit.Load[appConfiguration](configkit.Mapping("simple application settings", map[string]any{ +func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*configkit.Result[appConfiguration], error) { + source := configkit.Mapping("simple application settings", map[string]any{ "greeting": "Hello", - })) + }) + if selection.Config.Set { + source = configkit.File(selection.Config.Value) + } + options := configkit.Options[appConfiguration]{} + if selection.Overlay.Set { + options.Overlay = selection.Overlay.Value + options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."} + } + return configkit.Load(source, options) } func newApp() (*commandkit.App, error) { diff --git a/integrations/ckconfig/examples/simple/main_test.go b/integrations/ckconfig/examples/simple/main_test.go new file mode 100644 index 0000000..1ef36cd --- /dev/null +++ b/integrations/ckconfig/examples/simple/main_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "context" + "io" + "testing" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + "gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig" + "go.yaml.in/yaml/v4" +) + +func TestSimpleAppComparesFixturesAndKeepsDefaultCommands(t *testing.T) { + app, err := newApp() + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "fixtures/before.yml", "--against-config", "fixtures/after.yml"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("fixture comparison outcome = %#v", outcome) + } + var report yaml.Node + if err := yaml.Unmarshal(output.Bytes(), &report); err != nil { + t.Fatalf("comparison report is not YAML: %q: %v", output.String(), err) + } + changes := field(report.Content[0], "changes") + greeting := field(changes, "greeting") + if greeting == nil || greeting.Value != "Goodbye" { + t.Fatalf("fixture comparison report = %q", output.String()) + } + + output.Reset() + outcome = app.Execute(context.Background(), []string{"config", "diff", "--config", "fixtures/before.yml", "--against-config", "fixtures/before.yml"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("equal fixture comparison outcome = %#v", outcome) + } + if reportNode, err := decodeReport(output.Bytes()); err != nil || len(field(reportNode, "additions").Content) != 0 || len(field(reportNode, "changes").Content) != 0 || len(field(reportNode, "deletions").Content) != 0 { + t.Fatalf("equal fixture report = %q, decode error %v", output.String(), err) + } + + output.Reset() + outcome = app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "greeting: Hello\n" { + t.Fatalf("default show outcome/output = %#v / %q", outcome, output.String()) + } + + output.Reset() + outcome = app.Execute(context.Background(), []string{"greet"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || len(output.String()) == 0 || output.String()[:5] != "Hello" { + t.Fatalf("default greeting outcome/output = %#v / %q", outcome, output.String()) + } +} + +func TestSimpleLoaderDoesNotReplaceExplicitEmptyConfiguration(t *testing.T) { + _, err := loadAppConfiguration(context.Background(), ckconfig.Selection{Config: commandkit.Supplied[string]{Set: true}}) + if err == nil { + t.Fatal("explicit empty configuration fell back to the default source") + } +} + +func field(mapping *yaml.Node, name string) *yaml.Node { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return nil + } + for index := 0; index+1 < len(mapping.Content); index += 2 { + if mapping.Content[index].Value == name { + return mapping.Content[index+1] + } + } + return nil +} + +func decodeReport(data []byte) (*yaml.Node, error) { + var document yaml.Node + if err := yaml.Unmarshal(data, &document); err != nil { + return nil, err + } + return document.Content[0], nil +}