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
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

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 {
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 {