diff --git a/docs/cli.md b/docs/cli.md index 8b8ec3e..b87acd8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -465,10 +465,11 @@ configuration inspection API. PromptKit owns the meaning of that native comparison; CommandKit uses the existing `config diff` preparation and report path. `--config` and `--overlay` select the left side. `--against-config` and -`--against-overlay` independently override the right side. When a right-side -field is omitted, it inherits the corresponding left value and supplied state; -an explicit empty string overrides it and remains supplied. Equal selections are -allowed; their equality alone does not cause CommandKit to skip the provider. +`--against-overlay` select the right side. An omitted field keeps that side's +ordinary application loading behavior and unsupplied state; an explicit empty +string remains supplied. Application selectors affect the left side, while +declared `--against-*` counterparts affect the right. Equal selections are +allowed and do not cause CommandKit to skip the provider. Automatic output is YAML. The shared `--format` also accepts YAML, JSON, and text; text uses the same structured representation. For example: diff --git a/docs/consumers/guide.md b/docs/consumers/guide.md index 1b11b63..6b0750d 100644 --- a/docs/consumers/guide.md +++ b/docs/consumers/guide.md @@ -237,8 +237,10 @@ Use `ckconfig.Command(ckconfig.Suite[I, S]{...})` when the application needs multiple named scopes, selector flags, scope-aware input checks, or shared acquisition state or a custom comparison provider. A declared `Diff` capability uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand` provide the -ConfigKit-backed implementation. Each comparison side gets its own initialized -and checked input, while state is shared by the invocation. Factory results +ConfigKit-backed implementation. `Selector.AgainstName` opts a selector into an +independent right-side flag. Each comparison side gets its own initialized and +checked input, with only its supplied selectors applied; state is shared by the +invocation. Factory results remain caller-owned, so separate initialization calls do not guarantee distinct objects. See [ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 60dbb89..a9853a1 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Shared reporting and PromptKit v0.20.0 implementation plan -Status: implementation in progress; Stages 1–14 complete. +Status: implementation in progress; Stages 1–15 complete. ## Scope, authority, and execution @@ -539,6 +539,12 @@ failure, independently supplied diagnostics, and metadata/help without loading. ## Stage 15: Independent comparison selector inputs +**Complete.** Comparison sides now capture and apply independently declared +selectors and configuration selections. `FlagValues.ApplySuppliedTo` was added +to core because ordinary captured replay also applies flag defaults; this keeps +each side's factory defaults intact while retaining required checks and ordered +occurrences. Root, ckconfig, and ckprompt workspace suites passed with Go 1.27.0. + Replace inheritance in [input.go](../../integrations/ckconfig/input.go). Add `Selector.AgainstName string`; an empty name declares no right-side flag. For a declared counterpart use Stage 6's `RenameFlag` to retain domain parsing diff --git a/flag_values.go b/flag_values.go index 757a2bd..eb3eec4 100644 --- a/flag_values.go +++ b/flag_values.go @@ -76,6 +76,17 @@ func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues // occurrence values in command-line order. Repeated core-owned values are copied // for each application. func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error { + return values.applyTo(target, false, selected...) +} + +// ApplySuppliedTo applies only explicitly supplied selected flags, preserving +// the destination's existing values for omitted flags. It checks selected +// bindings and required values like ApplyTo and preserves occurrence order. +func (values FlagValues[I]) ApplySuppliedTo(target *I, selected ...Flag[I]) error { + return values.applyTo(target, true, selected...) +} + +func (values FlagValues[I]) applyTo(target *I, suppliedOnly bool, selected ...Flag[I]) error { if target == nil { return fmt.Errorf("%w: destination is nil", ErrFlagWiring) } @@ -92,6 +103,9 @@ func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error { if binding.meta.Required && !prepared.supplied { return fmt.Errorf("flag --%s: %w", binding.meta.Name, ErrFlagInput) } + if suppliedOnly && !prepared.supplied { + continue + } preparedValues = append(preparedValues, prepared) } var ordered []preparedOccurrence[I] diff --git a/integrations/ckconfig/comparison_input_test.go b/integrations/ckconfig/comparison_input_test.go index 6d5aec9..3fa595b 100644 --- a/integrations/ckconfig/comparison_input_test.go +++ b/integrations/ckconfig/comparison_input_test.go @@ -10,57 +10,6 @@ import ( configkit "go.maximumdirect.net/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}, @@ -125,7 +74,6 @@ func TestComparisonPreparation(t *testing.T) { 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) diff --git a/integrations/ckconfig/comparison_preparation_test.go b/integrations/ckconfig/comparison_preparation_test.go index d5a22ef..7f77147 100644 --- a/integrations/ckconfig/comparison_preparation_test.go +++ b/integrations/ckconfig/comparison_preparation_test.go @@ -62,7 +62,7 @@ func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) } return struct{}{} }, - Selectors: []Selector[*input]{{Binding: enabled}, {Binding: limit}, {Binding: tags}}, + Selectors: []Selector[*input]{{Binding: enabled, AgainstName: "against-enabled"}, {Binding: limit, AgainstName: "against-limit"}, {Binding: tags, AgainstName: "against-tag"}}, CheckInput: func(scope string, selection Selection, value *input) error { checkCalls++ preparedInputs = append(preparedInputs, value) @@ -91,7 +91,7 @@ func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) app := newConfigTestApp(t, Command(suite)) outcome := app.Execute(context.Background(), []string{ "config", "diff", "--config", "same.yml", "--against-config", "same.yml", "--overlay=", - "--enabled=false", "--limit=0", "--tag", "alpha", "--tag", "beta", + "--enabled=false", "--limit=0", "--tag", "alpha", "--tag", "beta", "--against-enabled=false", "--against-limit=0", "--against-tag", "alpha", "--against-tag", "beta", "--against-overlay=", }, commandkit.Streams{Out: io.Discard, Err: io.Discard}) if outcome.Kind != commandkit.OutcomeSuccess { t.Fatalf("valid comparison outcome = %#v", outcome) @@ -99,7 +99,7 @@ func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 { t.Fatalf("preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls) } - if parseCalls != 3 { + if parseCalls != 6 { t.Fatalf("typed values were converted %d times, want once per supplied occurrence", parseCalls) } wantSelection := Selection{ @@ -161,7 +161,7 @@ func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) { t.Fatalf("comparison outcome = %#v", outcome) } left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}} - right := Selection{Config: left.Config, Overlay: commandkit.Supplied[string]{Set: true}} + right := Selection{Overlay: commandkit.Supplied[string]{Set: true}} if request.Left.Selection != left || request.Right.Selection != right { t.Fatalf("provider selections = %#v", request) } diff --git a/integrations/ckconfig/contracts.go b/integrations/ckconfig/contracts.go index 79c0c68..59300f4 100644 --- a/integrations/ckconfig/contracts.go +++ b/integrations/ckconfig/contracts.go @@ -141,6 +141,9 @@ type Selector[I any] struct { Binding commandkit.Flag[I] // Scopes lists the scope names that receive this flag's value. Scopes []string + // AgainstName optionally declares the long name of an independent + // right-side comparison flag using the same parser and setter. + AgainstName string } // Suite declares the typed input, shared state, selectors, and configuration diff --git a/integrations/ckconfig/diff_execution_test.go b/integrations/ckconfig/diff_execution_test.go index e138d6e..e5f9145 100644 --- a/integrations/ckconfig/diff_execution_test.go +++ b/integrations/ckconfig/diff_execution_test.go @@ -14,7 +14,7 @@ import ( "go.yaml.in/yaml/v4" ) -func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *testing.T) { +func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndIndependentSelections(t *testing.T) { type settings struct { Greeting string `yaml:"greeting"` } @@ -32,7 +32,7 @@ func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *t if outcome.Kind != commandkit.OutcomeSuccess { t.Fatalf("default YAML comparison outcome = %#v", outcome) } - if len(selections) != 2 || !selections[0].Config.Set || selections[0].Config.Value != "before.yml" || !selections[1].Config.Set || selections[1].Config.Value != "after.yml" || selections[1].Overlay != selections[0].Overlay { + if len(selections) != 2 || !selections[0].Config.Set || selections[0].Config.Value != "before.yml" || !selections[1].Config.Set || selections[1].Config.Value != "after.yml" || selections[1].Overlay.Set { t.Fatalf("comparison selections = %#v", selections) } var document yaml.Node @@ -256,7 +256,7 @@ func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) { if len(observed) != 2 || observed[0].left.ID == observed[0].right.ID || observed[1].left.ID == observed[1].right.ID || observed[0].left.ID == observed[1].left.ID || observed[0].right.ID == observed[1].right.ID { t.Fatalf("input instances were reused: %#v", observed) } - if observed[0].left.Label != "current" || observed[0].right.Label != "current" || observed[1].left.Label != "current" || observed[1].right.Label != "current" { + if observed[0].left.Label != "current" || observed[0].right.Label != "" || observed[1].left.Label != "current" || observed[1].right.Label != "" { t.Fatalf("comparison inputs missed captured flag values: %#v", observed) } if observed[0].state == nil || observed[1].state == nil || observed[0].state == observed[1].state || observed[0].state.ID != 1 || observed[1].state.ID != 2 { diff --git a/integrations/ckconfig/family.go b/integrations/ckconfig/family.go index 8d07834..069baf7 100644 --- a/integrations/ckconfig/family.go +++ b/integrations/ckconfig/family.go @@ -23,8 +23,9 @@ type scopeInventory struct { } type preparedScope[I, S any] struct { - scope Scope[I, S] - bindings []commandkit.Flag[I] + scope Scope[I, S] + bindings []commandkit.Flag[I] + againstBindings []commandkit.Flag[I] } type preparedSuite[I, S any] struct { @@ -34,6 +35,7 @@ type preparedSuite[I, S any] struct { scopes []*preparedScope[I, S] byName map[string]*preparedScope[I, S] bindingsByOperation map[Operation][]commandkit.Flag[I] + againstBindings []commandkit.Flag[I] scopeChoices string operations []Operation inventory scopeInventory @@ -90,9 +92,13 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] { bindingsByOperation: make(map[Operation][]commandkit.Flag[I]), } selectors := make([]Selector[I], len(suite.Selectors)) + against := make([]commandkit.Flag[I], len(suite.Selectors)) for index, selector := range suite.Selectors { selector.Scopes = append([]string(nil), selector.Scopes...) selectors[index] = selector + if selector.AgainstName != "" { + against[index] = commandkit.RenameFlag(selector.Binding, selector.AgainstName) + } } seenNames := make(map[string]struct{}, len(suite.Scopes)) @@ -158,12 +164,18 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] { for scopeIndex := range prepared.scopes { if len(prepared.scopes[scopeIndex].scope.Operations) > 0 { prepared.scopes[scopeIndex].bindings = append(prepared.scopes[scopeIndex].bindings, selector.Binding) + if selector.AgainstName != "" && containsOperation(prepared.scopes[scopeIndex].scope.Operations, Diff) { + prepared.scopes[scopeIndex].againstBindings = append(prepared.scopes[scopeIndex].againstBindings, against[index]) + } } } } else { for _, name := range uniqueScopes { if scope, exists := prepared.byName[name]; exists && len(scope.scope.Operations) > 0 { scope.bindings = append(scope.bindings, selector.Binding) + if selector.AgainstName != "" && containsOperation(scope.scope.Operations, Diff) { + scope.againstBindings = append(scope.againstBindings, against[index]) + } } } } @@ -176,10 +188,13 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] { break } } - for _, selector := range selectors { + for index, selector := range selectors { for _, scope := range prepared.scopes { if containsOperation(scope.scope.Operations, operation) && selectorApplies(selector.Scopes, scope.scope.Name) { prepared.bindingsByOperation[operation] = append(prepared.bindingsByOperation[operation], selector.Binding) + if operation == Diff && selector.AgainstName != "" { + prepared.againstBindings = append(prepared.againstBindings, against[index]) + } break } } diff --git a/integrations/ckconfig/independent_comparison_test.go b/integrations/ckconfig/independent_comparison_test.go new file mode 100644 index 0000000..6581e61 --- /dev/null +++ b/integrations/ckconfig/independent_comparison_test.go @@ -0,0 +1,79 @@ +package ckconfig + +import ( + "context" + "io" + "reflect" + "testing" + + commandkit "go.maximumdirect.net/commandkit" + configkit "go.maximumdirect.net/configkit" +) + +func TestComparisonSidesKeepIndependentSelectionsAndDefaults(t *testing.T) { + type input struct { + Label commandkit.Supplied[string] + Tags []string + } + label := commandkit.ValueFlag(commandkit.FlagMeta{Name: "label"}, (*string)(nil), parseConfigString, func(i *input, value commandkit.Supplied[string]) { i.Label = value }) + tags := commandkit.RepeatFlag(commandkit.FlagMeta{Name: "tag"}, []string(nil), parseConfigString, func(i *input, value commandkit.Supplied[[]string]) { i.Tags = value.Value }) + var pair ComparisonRequest[input] + calls := 0 + suite := Suite[input, struct{}]{ + NewInput: func() input { + return input{Label: commandkit.Supplied[string]{Value: "factory"}, Tags: []string{"factory"}} + }, + Selectors: []Selector[input]{{Binding: label, AgainstName: "against-label"}, {Binding: tags, AgainstName: "against-tag"}}, + Scopes: []Scope[input, struct{}]{{Name: "app", Operations: []Operation{Diff}, Compare: func(_ context.Context, request ComparisonRequest[input], _ struct{}) (commandkit.Acquired[[]configkit.Difference], error) { + calls++ + pair = request + return commandkit.Acquired[[]configkit.Difference]{}, nil + }}}, + } + app := newConfigTestApp(t, Command(suite)) + tests := []struct { + args []string + left, right Selection + leftLabel, rightLabel commandkit.Supplied[string] + leftTags, rightTags []string + }{ + {leftLabel: commandkit.Supplied[string]{Value: "factory"}, rightLabel: commandkit.Supplied[string]{Value: "factory"}, leftTags: []string{"factory"}, rightTags: []string{"factory"}}, + {args: []string{"--config=left", "--overlay=base", "--label=", "--tag=a", "--tag=b"}, left: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}, Overlay: commandkit.Supplied[string]{Value: "base", Set: true}}, leftLabel: commandkit.Supplied[string]{Set: true}, rightLabel: commandkit.Supplied[string]{Value: "factory"}, leftTags: []string{"a", "b"}, rightTags: []string{"factory"}}, + {args: []string{"--against-config=right", "--against-label=r", "--against-tag=x"}, right: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}, leftLabel: commandkit.Supplied[string]{Value: "factory"}, rightLabel: commandkit.Supplied[string]{Value: "r", Set: true}, leftTags: []string{"factory"}, rightTags: []string{"x"}}, + {args: []string{"--config=left", "--against-overlay=", "--label=l", "--against-label=r", "--tag=a", "--against-tag=b"}, left: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}, right: Selection{Overlay: commandkit.Supplied[string]{Set: true}}, leftLabel: commandkit.Supplied[string]{Value: "l", Set: true}, rightLabel: commandkit.Supplied[string]{Value: "r", Set: true}, leftTags: []string{"a"}, rightTags: []string{"b"}}, + } + for _, test := range tests { + result := app.Execute(context.Background(), append([]string{"config", "diff"}, test.args...), commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if result.Kind != commandkit.OutcomeSuccess || pair.Left.Selection != test.left || pair.Right.Selection != test.right || pair.Left.Input.Label != test.leftLabel || pair.Right.Input.Label != test.rightLabel || !reflect.DeepEqual(pair.Left.Input.Tags, test.leftTags) || !reflect.DeepEqual(pair.Right.Input.Tags, test.rightTags) { + t.Fatalf("args=%v outcome=%#v pair=%#v", test.args, result, pair) + } + } + if calls != len(tests) { + t.Fatalf("provider calls = %d", calls) + } +} + +func TestComparisonRequiredCounterpartsAndCollisions(t *testing.T) { + flag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "choice", Required: true}, (*string)(nil), parseConfigString, func(i *string, value commandkit.Supplied[string]) { *i = value.Value }) + calls := 0 + scope := Scope[string, struct{}]{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[string], struct{}) (commandkit.Acquired[[]configkit.Difference], error) { + calls++ + return commandkit.Acquired[[]configkit.Difference]{}, nil + }} + suite := Suite[string, struct{}]{Selectors: []Selector[string]{{Binding: flag, AgainstName: "against-choice"}}, Scopes: []Scope[string, struct{}]{scope}} + app := newConfigTestApp(t, Command(suite)) + for _, args := range [][]string{{"--choice=x"}, {"--against-choice=x"}} { + result := app.Execute(context.Background(), append([]string{"config", "diff"}, args...), commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if result.Kind != commandkit.OutcomeUsage || calls != 0 { + t.Fatalf("missing side %v: %#v", args, result) + } + } + result := app.Execute(context.Background(), []string{"config", "diff", "--choice=x", "--against-choice=y"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if result.Kind != commandkit.OutcomeSuccess || calls != 1 { + t.Fatalf("complete sides: %#v", result) + } + suite.Selectors[0].AgainstName = "against-config" + if _, err := commandkit.New(commandkit.Identity{Name: "app"}, Command(suite)); err == nil { + t.Fatal("conflicting counterpart accepted") + } +} diff --git a/integrations/ckconfig/input.go b/integrations/ckconfig/input.go index 8f2b858..964b8b5 100644 --- a/integrations/ckconfig/input.go +++ b/integrations/ckconfig/input.go @@ -9,12 +9,13 @@ import ( ) type configCommandInput[I, S any] struct { - selection Selection - against Selection - scope commandkit.Supplied[string] - path commandkit.Supplied[string] - values commandkit.FlagValues[I] - plan *configOperationPlan[I, S] + selection Selection + against Selection + scope commandkit.Supplied[string] + path commandkit.Supplied[string] + values commandkit.FlagValues[I] + againstValues commandkit.FlagValues[I] + plan *configOperationPlan[I, S] } type selectedScopeInput[I, S any] struct { @@ -85,6 +86,14 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio } return &(*input).values })...) + if operation == Diff { + flags = append(flags, commandkit.CaptureFlags(prepared.againstBindings, func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] { + if input == nil || *input == nil { + return nil + } + return &(*input).againstValues + })...) + } name, summary := operationDeclaration(operation) return commandkit.NewCommand(commandkit.CommandSpec[*configCommandInput[I, S]]{ @@ -169,13 +178,12 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O scopes: make([]selectedScopeInput[I, S], 0, len(selected)), } if operation == Diff { - input.against = inheritComparisonSelection(input.selection, input.against) scope := selected[0] - left, err := prepareScopeInput(prepared, input.values, scope, input.selection) + left, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, input.selection, true) if err != nil { return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err) } - right, err := prepareScopeInput(prepared, input.values, scope, input.against) + right, err := prepareScopeInput(prepared, input.againstValues, scope, scope.againstBindings, input.against, true) if err != nil { return fmt.Errorf("configuration scope %q right input: %w", scope.scope.Name, err) } @@ -190,7 +198,7 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O return nil } for _, scope := range selected { - value, err := prepareScopeInput(prepared, input.values, scope, input.selection) + value, err := prepareScopeInput(prepared, input.values, scope, scope.bindings, input.selection, false) if err != nil { return fmt.Errorf("configuration scope %q: %w", scope.scope.Name, err) } @@ -200,14 +208,18 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O return nil } -func prepareScopeInput[I, S any](prepared *preparedSuite[I, S], values commandkit.FlagValues[I], scope *preparedScope[I, S], selection Selection) (I, error) { +func prepareScopeInput[I, S any](prepared *preparedSuite[I, S], values commandkit.FlagValues[I], scope *preparedScope[I, S], bindings []commandkit.Flag[I], selection Selection, suppliedOnly bool) (I, error) { var value I if prepared.newInput != nil { value = prepared.newInput() } // Availability was checked before this helper, so every binding for the // selected scope belongs to the operation's captured union. - if err := values.ApplyTo(&value, scope.bindings...); err != nil { + apply := values.ApplyTo + if suppliedOnly { + apply = values.ApplySuppliedTo + } + if err := apply(&value, bindings...); err != nil { return value, err } if prepared.checkInput != nil { @@ -218,16 +230,6 @@ func prepareScopeInput[I, S any](prepared *preparedSuite[I, S], values commandki return value, 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 { diff --git a/integrations/ckprompt/comparison_composition_test.go b/integrations/ckprompt/comparison_composition_test.go index 3b3eab0..69d955c 100644 --- a/integrations/ckprompt/comparison_composition_test.go +++ b/integrations/ckprompt/comparison_composition_test.go @@ -64,7 +64,7 @@ func TestPublicConfigDiffUsesPromptKitPairAndPreparedInputs(t *testing.T) { events = append(events, "state") return &compositionState{token: len(events)} }, - Selectors: []ckconfig.Selector[*compositionInput]{{Binding: flag, Scopes: []string{"app", "promptkit"}}}, + Selectors: []ckconfig.Selector[*compositionInput]{{Binding: flag, AgainstName: "against-campaign", Scopes: []string{"app", "promptkit"}}}, CheckInput: func(scope string, selection ckconfig.Selection, input *compositionInput) error { if scope == "promptkit" { events = append(events, "check") @@ -90,7 +90,7 @@ func TestPublicConfigDiffUsesPromptKitPairAndPreparedInputs(t *testing.T) { rightSet bool }{ {name: "right selection is explicit", rightArgs: []string{"--against-config", "after.yml"}, rightValue: "after.yml", rightSet: true}, - {name: "right selection inherits", rightValue: "before.yml", rightSet: true}, + {name: "right selection remains omitted"}, {name: "empty right selection is supplied", rightArgs: []string{"--against-config", ""}, rightSet: true}, } { t.Run(test.name, func(t *testing.T) { @@ -100,7 +100,7 @@ func TestPublicConfigDiffUsesPromptKitPairAndPreparedInputs(t *testing.T) { loadedInputs = nil loadedStates = nil loadedSelections = nil - args := []string{"config", "diff", "--scope", "promptkit", "--config", "before.yml", "--campaign", "spring"} + args := []string{"config", "diff", "--scope", "promptkit", "--config", "before.yml", "--campaign", "spring", "--against-campaign", "spring"} args = append(args, test.rightArgs...) var output bytes.Buffer outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard}) @@ -153,7 +153,7 @@ func TestPublicConfigDiffUsesPromptKitPairAndPreparedInputs(t *testing.T) { } var diagnostics bytes.Buffer - failure := app.Execute(context.Background(), []string{"config", "diff", "--scope", "promptkit", "--config", "bad-left.yml", "--against-config", "right.yml", "--campaign", "spring"}, commandkit.Streams{Out: io.Discard, Err: &diagnostics}) + failure := app.Execute(context.Background(), []string{"config", "diff", "--scope", "promptkit", "--config", "bad-left.yml", "--against-config", "right.yml", "--campaign", "spring", "--against-campaign", "spring"}, commandkit.Streams{Out: io.Discard, Err: &diagnostics}) if failure.Kind != commandkit.OutcomeOperational || !strings.Contains(diagnostics.String(), `Configuration scope "promptkit" (left):`) { t.Fatalf("installed load failure = %#v, diagnostics %q", failure, diagnostics.String()) } diff --git a/integrations/ckprompt/examples/composed/README.md b/integrations/ckprompt/examples/composed/README.md index 0e5e654..5133418 100644 --- a/integrations/ckprompt/examples/composed/README.md +++ b/integrations/ckprompt/examples/composed/README.md @@ -12,7 +12,7 @@ go run . config scopes go run . config validate --scope all --campaign spring --limit=0 go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring --limit=0 -go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring +go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring --against-campaign spring go run . promptkit list prompts --config fixtures/before.yml --campaign spring go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml go run . promptkit list backends --campaign spring diff --git a/integrations/ckprompt/examples/composed/main.go b/integrations/ckprompt/examples/composed/main.go index e963183..fd820ef 100644 --- a/integrations/ckprompt/examples/composed/main.go +++ b/integrations/ckprompt/examples/composed/main.go @@ -234,8 +234,8 @@ func newAppWithEngineLoader( return nil }, Selectors: []ckconfig.Selector[*inspectionInput]{ - {Binding: campaignSelector(), Scopes: []string{"app", "promptkit"}}, - {Binding: limitFlag, Scopes: []string{"app"}}, + {Binding: campaignSelector(), AgainstName: "against-campaign", Scopes: []string{"app", "promptkit"}}, + {Binding: limitFlag, AgainstName: "against-limit", Scopes: []string{"app"}}, }, Scopes: []ckconfig.Scope[*inspectionInput, *runCache]{appScope, engineScope}, }) diff --git a/integrations/ckprompt/examples/composed/main_test.go b/integrations/ckprompt/examples/composed/main_test.go index 1e438af..752ce56 100644 --- a/integrations/ckprompt/examples/composed/main_test.go +++ b/integrations/ckprompt/examples/composed/main_test.go @@ -297,7 +297,7 @@ func TestPromptKitScopeDiffAndCatalogsUseSelectedCampaign(t *testing.T) { t.Fatal(err) } var output bytes.Buffer - outcome := app.Execute(context.Background(), []string{"config", "diff", "--scope", "promptkit", "--config", "fixtures/before.yml", "--against-config", "fixtures/after.yml", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard}) + outcome := app.Execute(context.Background(), []string{"config", "diff", "--scope", "promptkit", "--config", "fixtures/before.yml", "--against-config", "fixtures/after.yml", "--campaign", "spring", "--against-campaign", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard}) if outcome.Kind != commandkit.OutcomeSuccess { t.Fatalf("PromptKit diff outcome = %#v", outcome) }