Add comparison selection and format flags

This commit is contained in:
2026-10-11 21:39:19 +00:00
parent 018f90a23c
commit d1591463ee
3 changed files with 229 additions and 6 deletions

View File

@@ -163,11 +163,18 @@ without prematurely constructing application inputs or shared state.
`--path` remains explain-only; against flags remain diff-only. Keep ordinary `--path` remains explain-only; against flags remain diff-only. Keep ordinary
format defaults and singleton flag behavior unchanged. format defaults and singleton flag behavior unchanged.
**Completion evidence:** the roadmap's inheritance table, including explicit **Completion evidence — complete:** right-side configuration and overlay fields
empty and single-field overrides; default/explicit YAML and unsupported execution inherit independently with their supplied state, while explicit empty overrides
formats; unknown/all/unsupported scopes failing before factories. Verify against remain supplied. The `diff` command defaults to YAML, accepts explicit YAML, and
flags appear in diff help, not other operations. Successful pair preparation is rejects text and unsupported formats. Its help exposes both right-side flags;
completed in Stage 4; acquisition and delivery remain for Stage 8. 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 ## Stage 4: Prepare separate typed inputs for the selected pair

View 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)
}
}

View File

@@ -10,6 +10,7 @@ import (
type configCommandInput[I, S any] struct { type configCommandInput[I, S any] struct {
selection Selection selection Selection
against Selection
scope commandkit.Supplied[string] scope commandkit.Supplied[string]
format commandkit.Supplied[string] format commandkit.Supplied[string]
path 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 { func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operation Operation) commandkit.Command {
defaultScope := "app" defaultScope := "app"
defaultFormat := "text" defaultFormat := "text"
if operation == Show { if operation == Show || operation == Diff {
defaultFormat = "yaml" defaultFormat = "yaml"
} }
flags := []commandkit.Flag[*configCommandInput[I, S]]{ flags := []commandkit.Flag[*configCommandInput[I, S]]{
@@ -52,6 +53,18 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
(*input).selection.Overlay = value (*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 { if operation == Explain {
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain.", Required: true}, (*string)(nil), parseConfigString, 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 })) 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." return "sources", "List configuration sources."
case Explain: case Explain:
return "explain", "Explain a configuration path." return "explain", "Explain a configuration path."
case Diff:
return "diff", "Compare two configuration selections."
default: default:
return string(operation), "Inspect configuration." 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" { if operation == Show && format != "yaml" {
return fmt.Errorf("unsupported configuration show format %q", format) 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" scopeName := "app"
if input.scope.Set { if input.scope.Set {
@@ -151,6 +169,11 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
path: path, path: path,
scopes: make([]selectedScopeInput[I, S], 0, len(selected)), 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 { for _, scope := range selected {
var value I var value I
if prepared.newInput != nil { if prepared.newInput != nil {
@@ -172,6 +195,16 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
return nil 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 { func joinOperations(operations []Operation) string {
values := make([]string, len(operations)) values := make([]string, len(operations))
for index, operation := range operations { for index, operation := range operations {