Add comparison selection and format flags
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
183
integrations/ckconfig/comparison_input_test.go
Normal file
183
integrations/ckconfig/comparison_input_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user