diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a06f2f7..65eac6d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -163,11 +163,18 @@ without prematurely constructing application inputs or shared state. `--path` remains explain-only; against flags remain diff-only. Keep ordinary format defaults and singleton flag behavior unchanged. -**Completion evidence:** the roadmap's inheritance table, including explicit -empty and single-field overrides; default/explicit YAML and unsupported execution -formats; unknown/all/unsupported scopes failing before factories. Verify against -flags appear in diff help, not other operations. Successful pair preparation is -completed in Stage 4; acquisition and delivery remain for Stage 8. +**Completion evidence — complete:** right-side configuration and overlay fields +inherit independently with their supplied state, while explicit empty overrides +remain supplied. The `diff` command defaults to YAML, accepts explicit YAML, and +rejects text and unsupported formats. Its help exposes both right-side flags; +validate, show, sources, and explain do not, and `--path` remains explain-only. +Comparison scope preflight defaults to `app` and rejects `all`, unknown, and +unsupported scopes before suite factories run. Valid comparison preparation +stores the resolved right selection without constructing application input or +shared state. The existing show default and other operation defaults remain +unchanged. Focused tests and the full ckconfig and dependent ckp suites pass. +Separate typed pair preparation remains in Stage 4; acquisition and delivery +remain in Stage 8. ## Stage 4: Prepare separate typed inputs for the selected pair diff --git a/integrations/ckconfig/comparison_input_test.go b/integrations/ckconfig/comparison_input_test.go new file mode 100644 index 0000000..b89328b --- /dev/null +++ b/integrations/ckconfig/comparison_input_test.go @@ -0,0 +1,183 @@ +package ckconfig + +import ( + "context" + "io" + "strings" + "testing" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestComparisonSelectionInheritsFieldsIndependently(t *testing.T) { + leftConfig := commandkit.Supplied[string]{Value: "before.yml", Set: true} + leftOverlay := commandkit.Supplied[string]{Value: "base", Set: true} + tests := []struct { + name string + left Selection + right Selection + want Selection + }{ + { + name: "omitted fields stay omitted", + }, + { + name: "both fields inherit supplied state", + left: Selection{Config: leftConfig, Overlay: leftOverlay}, + want: Selection{Config: leftConfig, Overlay: leftOverlay}, + }, + { + name: "explicit empty config overrides while overlay inherits", + left: Selection{Config: leftConfig, Overlay: leftOverlay}, + right: Selection{Config: commandkit.Supplied[string]{Set: true}}, + want: Selection{Config: commandkit.Supplied[string]{Set: true}, Overlay: leftOverlay}, + }, + { + name: "explicit overlay overrides while config inherits", + left: Selection{Config: leftConfig, Overlay: leftOverlay}, + right: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}}, + want: Selection{Config: leftConfig, Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}}, + }, + { + name: "explicit empty overlay remains supplied", + left: Selection{Config: leftConfig, Overlay: leftOverlay}, + right: Selection{Overlay: commandkit.Supplied[string]{Set: true}}, + want: Selection{Config: leftConfig, Overlay: commandkit.Supplied[string]{Set: true}}, + }, + { + name: "both explicit right fields replace left", + left: Selection{Config: leftConfig, Overlay: leftOverlay}, + right: Selection{Config: commandkit.Supplied[string]{Value: "after.yml", Set: true}, Overlay: commandkit.Supplied[string]{Value: "new", Set: true}}, + want: Selection{Config: commandkit.Supplied[string]{Value: "after.yml", Set: true}, Overlay: commandkit.Supplied[string]{Value: "new", Set: true}}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := inheritComparisonSelection(test.left, test.right); got != test.want { + t.Fatalf("inherited selection = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) { + scope := Scope[struct{}, struct{}]{ + Name: "app", Operations: []Operation{Diff, Show, Explain, Validate, Sources}, + Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + return commandkit.Acquired[View]{}, nil + }, + Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) { + return commandkit.Acquired[[]configkit.Difference]{}, nil + }, + } + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}})) + help := func(args ...string) string { + t.Helper() + var output strings.Builder + app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard}) + return output.String() + } + + diffHelp := help("config", "diff", "--help") + if !strings.Contains(diffHelp, "--against-config") || !strings.Contains(diffHelp, "--against-overlay") { + t.Fatalf("diff help omits right-side selection flags:\n%s", diffHelp) + } + if !strings.Contains(diffHelp, "default: yaml") { + t.Fatalf("diff help does not show the YAML default:\n%s", diffHelp) + } + for _, operation := range []string{"validate", "show", "sources", "explain"} { + operationHelp := help("config", operation, "--help") + if strings.Contains(operationHelp, "--against-config") || strings.Contains(operationHelp, "--against-overlay") { + t.Errorf("%s exposes comparison-only flags:\n%s", operation, operationHelp) + } + if operation == "show" && !strings.Contains(operationHelp, "default: yaml") { + t.Errorf("show format default changed:\n%s", operationHelp) + } + if operation != "show" && !strings.Contains(operationHelp, "default: text") { + t.Errorf("%s format default changed:\n%s", operation, operationHelp) + } + if operation != "explain" && strings.Contains(operationHelp, "--path") { + t.Errorf("%s exposes the explain-only path flag:\n%s", operation, operationHelp) + } + } + explainHelp := help("config", "explain", "--help") + if !strings.Contains(explainHelp, "--path") { + t.Fatalf("explain flags changed:\n%s", explainHelp) + } +} + +func TestComparisonPreparationAcceptsOnlyYAMLWithoutCreatingInputs(t *testing.T) { + inputCalls, stateCalls := 0, 0 + prepared := captureSuite(Suite[struct{}, struct{}]{ + NewInput: func() struct{} { inputCalls++; return struct{}{} }, + NewState: func() struct{} { stateCalls++; return struct{}{} }, + Scopes: []Scope[struct{}, struct{}]{{ + Name: "app", Operations: []Operation{Diff}, + Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) { + return commandkit.Acquired[[]configkit.Difference]{}, nil + }, + }}, + }) + + input := &configCommandInput[struct{}, struct{}]{ + selection: Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}, + against: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}}, + format: commandkit.Supplied[string]{Value: "yaml", Set: true}, + } + if err := prepareConfigOperation(prepared, Diff, input); err != nil { + t.Fatalf("explicit YAML format rejected: %v", err) + } + if input.plan == nil || input.plan.format != "yaml" { + t.Fatalf("comparison plan = %#v", input.plan) + } + if input.plan.scopeName != "app" { + t.Fatalf("comparison default scope = %q, want app", input.plan.scopeName) + } + if want := (Selection{ + Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}, + Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}, + }); input.against != want { + t.Fatalf("resolved right selection = %#v, want %#v", input.against, want) + } + + for _, format := range []string{"text", "json"} { + input := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: format}} + if err := prepareConfigOperation(prepared, Diff, input); err == nil { + t.Errorf("comparison format %q was accepted", format) + } + } + if inputCalls != 0 || stateCalls != 0 { + t.Fatalf("comparison preparation created input/state: %d/%d", inputCalls, stateCalls) + } +} + +func TestComparisonAvailabilityPreflightPrecedesFactories(t *testing.T) { + inputCalls, stateCalls, acquireCalls, compareCalls := 0, 0, 0, 0 + prepared := captureSuite(Suite[struct{}, struct{}]{ + NewInput: func() struct{} { inputCalls++; return struct{}{} }, + NewState: func() struct{} { stateCalls++; return struct{}{} }, + Scopes: []Scope[struct{}, struct{}]{ + {Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) { + compareCalls++ + return commandkit.Acquired[[]configkit.Difference]{}, nil + }}, + {Name: "plain", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + acquireCalls++ + return commandkit.Acquired[View]{}, nil + }}, + }, + }) + for _, scope := range []string{"missing", "all", "plain"} { + input := &configCommandInput[struct{}, struct{}]{ + scope: commandkit.Supplied[string]{Value: scope, Set: true}, + format: commandkit.Supplied[string]{Value: "yaml"}, + } + if err := prepareConfigOperation(prepared, Diff, input); err == nil { + t.Errorf("comparison scope %q was accepted", scope) + } + } + if inputCalls != 0 || stateCalls != 0 || acquireCalls != 0 || compareCalls != 0 { + t.Fatalf("failed preflight invoked suite work: input=%d state=%d acquire=%d compare=%d", inputCalls, stateCalls, acquireCalls, compareCalls) + } +} diff --git a/integrations/ckconfig/input.go b/integrations/ckconfig/input.go index d6a63ef..fbc7bf9 100644 --- a/integrations/ckconfig/input.go +++ b/integrations/ckconfig/input.go @@ -10,6 +10,7 @@ import ( type configCommandInput[I, S any] struct { selection Selection + against Selection scope commandkit.Supplied[string] format commandkit.Supplied[string] path commandkit.Supplied[string] @@ -35,7 +36,7 @@ type configOperationPlan[I, S any] struct { func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operation Operation) commandkit.Command { defaultScope := "app" defaultFormat := "text" - if operation == Show { + if operation == Show || operation == Diff { defaultFormat = "yaml" } flags := []commandkit.Flag[*configCommandInput[I, S]]{ @@ -52,6 +53,18 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio (*input).selection.Overlay = value }), } + if operation == Diff { + flags = append(flags, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side."}, (*string)(nil), parseConfigString, + func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { + (*input).against.Config = value + }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side."}, (*string)(nil), parseConfigString, + func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { + (*input).against.Overlay = value + }), + ) + } if operation == Explain { flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain.", Required: true}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value })) @@ -90,6 +103,8 @@ func operationDeclaration(operation Operation) (string, string) { return "sources", "List configuration sources." case Explain: return "explain", "Explain a configuration path." + case Diff: + return "diff", "Compare two configuration selections." default: return string(operation), "Inspect configuration." } @@ -103,6 +118,9 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O if operation == Show && format != "yaml" { return fmt.Errorf("unsupported configuration show format %q", format) } + if operation == Diff && format != "yaml" { + return fmt.Errorf("unsupported configuration diff format %q", format) + } scopeName := "app" if input.scope.Set { @@ -151,6 +169,11 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O path: path, scopes: make([]selectedScopeInput[I, S], 0, len(selected)), } + if operation == Diff { + input.against = inheritComparisonSelection(input.selection, input.against) + input.plan = plan + return nil + } for _, scope := range selected { var value I if prepared.newInput != nil { @@ -172,6 +195,16 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O return nil } +func inheritComparisonSelection(left, right Selection) Selection { + if !right.Config.Set { + right.Config = left.Config + } + if !right.Overlay.Set { + right.Overlay = left.Overlay + } + return right +} + func joinOperations(operations []Operation) string { values := make([]string, len(operations)) for index, operation := range operations {