package ckconfig import ( "context" "errors" "io" "strconv" "strings" "testing" commandkit "gitea.maximumdirect.net/eric/CommandKit" configkit "gitea.maximumdirect.net/eric/ConfigKit" ) func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) { type input struct { Enabled commandkit.Supplied[bool] Limit commandkit.Supplied[int] Tags commandkit.Supplied[[]string] } var inputCalls, checkCalls, stateCalls int var parseCalls int var preparedInputs []*input var selections []Selection enabled := commandkit.BoolFlag[*input](commandkit.FlagMeta{Name: "enabled"}, true, func(value **input, supplied commandkit.Supplied[bool]) { (*value).Enabled = supplied }) limit := commandkit.ValueFlag[*input](commandkit.FlagMeta{Name: "limit", Required: true}, (*int)(nil), func(raw string) (int, error) { parseCalls++ return strconv.Atoi(raw) }, func(value **input, supplied commandkit.Supplied[int]) { (*value).Limit = supplied }) tags := commandkit.RepeatFlag[*input](commandkit.FlagMeta{Name: "tag"}, nil, func(raw string) (string, error) { parseCalls++ return raw, nil }, func(value **input, supplied commandkit.Supplied[[]string]) { (*value).Tags = supplied }) suite := Suite[*input, struct{}]{ NewInput: func() *input { inputCalls++ return &input{} }, NewState: func() struct{} { stateCalls++ if checkCalls != 2 { t.Errorf("shared state created after %d input checks", checkCalls) } if len(preparedInputs) != 2 || preparedInputs[0] == preparedInputs[1] { t.Errorf("inputs were not initialized separately: %#v", preparedInputs) } else { left, right := preparedInputs[0].Tags.Value, preparedInputs[1].Tags.Value if len(left) != 2 || len(right) != 2 || &left[0] == &right[0] { t.Errorf("repeated values share storage: left=%#v right=%#v", left, right) } else { left[0] = "changed" if right[0] != "alpha" { t.Errorf("mutating one side changed the other: %#v", right) } } } return struct{}{} }, Selectors: []Selector[*input]{{Binding: enabled}, {Binding: limit}, {Binding: tags}}, CheckInput: func(scope string, selection Selection, value *input) error { checkCalls++ preparedInputs = append(preparedInputs, value) selections = append(selections, selection) if scope != "app" || value == nil { return errors.New("unexpected scope input") } if !value.Enabled.Set || value.Enabled.Value { return errors.New("explicit false was not preserved") } if !value.Limit.Set || value.Limit.Value != 0 { return errors.New("explicit zero was not preserved") } if !value.Tags.Set || len(value.Tags.Value) != 2 || value.Tags.Value[0] != "alpha" || value.Tags.Value[1] != "beta" { return errors.New("repeated input was not preserved") } return nil }, Scopes: []Scope[*input, struct{}]{{ Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[*input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) { return commandkit.Acquired[[]configkit.Difference]{}, nil }, }}, } 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", }, commandkit.Streams{Out: io.Discard, Err: io.Discard}) if outcome.Kind != commandkit.OutcomeSuccess { t.Fatalf("valid comparison outcome = %#v", outcome) } if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 { t.Fatalf("preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls) } if parseCalls != 3 { t.Fatalf("typed values were converted %d times, want once per supplied occurrence", parseCalls) } wantSelection := Selection{ Config: commandkit.Supplied[string]{Value: "same.yml", Set: true}, Overlay: commandkit.Supplied[string]{Set: true}, } if len(selections) != 2 || selections[0] != wantSelection || selections[1] != wantSelection { t.Fatalf("side selections = %#v, want equal %#v values", selections, wantSelection) } } func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) { type input struct{ ID int } type state struct{ Label string } inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0 checked := make(map[Selection]input) shared := &state{Label: "shared"} var request ComparisonRequest[input] app := newConfigTestApp(t, Command(Suite[input, *state]{ NewInput: func() input { inputCalls++ return input{ID: inputCalls} }, NewState: func() *state { stateCalls++ if checkCalls != 2 { t.Errorf("shared state created after %d input checks", checkCalls) } return shared }, CheckInput: func(scope string, selection Selection, value input) error { checkCalls++ if scope != "app" { return errors.New("unexpected scope") } checked[selection] = value return nil }, Scopes: []Scope[input, *state]{{ Name: "app", Operations: []Operation{Show, Diff}, Acquire: func(context.Context, Request, input, *state) (commandkit.Acquired[View], error) { acquireCalls++ return commandkit.Acquired[View]{}, errors.New("ordinary acquisition reached during comparison") }, Compare: func(_ context.Context, pair ComparisonRequest[input], value *state) (commandkit.Acquired[[]configkit.Difference], error) { compareCalls++ if checkCalls != 2 || stateCalls != 1 || value != shared { t.Errorf("provider reached with checks=%d states=%d shared=%t", checkCalls, stateCalls, value == shared) } request = pair return commandkit.Acquired[[]configkit.Difference]{}, nil }, }}, })) outcome := app.Execute(context.Background(), []string{ "config", "diff", "--config", "before.yml", "--against-overlay=", }, commandkit.Streams{Out: io.Discard, Err: io.Discard}) if outcome.Kind != commandkit.OutcomeSuccess { 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}} if request.Left.Selection != left || request.Right.Selection != right { t.Fatalf("provider selections = %#v", request) } if len(checked) != 2 || request.Left.Input != checked[left] || request.Right.Input != checked[right] { t.Fatalf("provider inputs = %#v, checked = %#v", request, checked) } if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 { t.Fatalf("invocation calls input=%d check=%d state=%d compare=%d acquire=%d", inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls) } } func TestComparisonDoesNotRetainPartialPlanAfterSideFailure(t *testing.T) { checkCalls := 0 failure := errors.New("right input rejected") prepared := captureSuite(Suite[struct{}, struct{}]{ CheckInput: func(string, Selection, struct{}) error { checkCalls++ if checkCalls == 2 { return failure } return nil }, 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 }, }}, }) commandInput := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}} if err := prepareConfigOperation(prepared, Diff, commandInput); !errors.Is(err, failure) { t.Fatalf("right-side input failure = %v", err) } if commandInput.plan != nil || checkCalls != 2 { t.Fatalf("failed pair retained partial state: plan=%#v checks=%d", commandInput.plan, checkCalls) } } func TestComparisonNilInputFactoryUsesZeroValue(t *testing.T) { checkCalls := 0 prepared := captureSuite(Suite[*int, struct{}]{ CheckInput: func(_ string, _ Selection, value *int) error { checkCalls++ if value != nil { return errors.New("nil factory did not supply the zero value") } return nil }, Scopes: []Scope[*int, struct{}]{{ Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[*int], struct{}) (commandkit.Acquired[[]configkit.Difference], error) { return commandkit.Acquired[[]configkit.Difference]{}, nil }, }}, }) commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}} if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil { t.Fatal(err) } request := commandInput.plan.comparison.request if checkCalls != 2 || request.Left.Input != nil || request.Right.Input != nil { t.Fatalf("nil input value changed: checks=%d request=%#v", checkCalls, request) } } func TestComparisonPreservesCallerOwnedInputAliases(t *testing.T) { shared := new(int) inputCalls, checkCalls := 0, 0 prepared := captureSuite(Suite[*int, struct{}]{ NewInput: func() *int { inputCalls++; return shared }, CheckInput: func(_ string, _ Selection, value *int) error { checkCalls++ if value != shared { return errors.New("caller-owned alias was replaced") } return nil }, Scopes: []Scope[*int, struct{}]{{ Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[*int], struct{}) (commandkit.Acquired[[]configkit.Difference], error) { return commandkit.Acquired[[]configkit.Difference]{}, nil }, }}, }) commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}} if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil { t.Fatal(err) } request := commandInput.plan.comparison.request if inputCalls != 2 || checkCalls != 2 || request.Left.Input != shared || request.Right.Input != shared { t.Fatalf("caller-owned alias changed: calls=%d/%d request=%#v", inputCalls, checkCalls, request) } } func TestComparisonInputFailureStopsBeforeStateAndPreservesWiringClassification(t *testing.T) { var inputCalls, checkCalls, stateCalls int suite := Suite[struct{}, struct{}]{ NewInput: func() struct{} { inputCalls++; return struct{}{} }, NewState: func() struct{} { stateCalls++; return struct{}{} }, CheckInput: func(string, Selection, struct{}) error { checkCalls++ if checkCalls == 2 { return commandkit.ErrFlagWiring } return nil }, 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 }, }}, } app := newConfigTestApp(t, Command(suite)) outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, commandkit.ErrFlagWiring) { t.Fatalf("right-side wiring failure = %#v", outcome) } if !strings.Contains(outcome.Cause.Error(), `scope "app" right input`) { t.Fatalf("right-side error lacks scope context: %v", outcome.Cause) } if inputCalls != 2 || checkCalls != 2 || stateCalls != 0 { t.Fatalf("failed preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls) } } func TestComparisonMissingRequiredSelectorIsUsageBeforeState(t *testing.T) { type input struct{ Token commandkit.Supplied[string] } var inputCalls, checkCalls, stateCalls int token := commandkit.ValueFlag(commandkit.FlagMeta{Name: "token", Required: true}, (*string)(nil), parseConfigString, func(value *input, supplied commandkit.Supplied[string]) { value.Token = supplied }) suite := Suite[input, struct{}]{ NewInput: func() input { inputCalls++; return input{} }, NewState: func() struct{} { stateCalls++; return struct{}{} }, Selectors: []Selector[input]{{Binding: token, Scopes: []string{"app"}}}, CheckInput: func(string, Selection, input) error { checkCalls++; return nil }, Scopes: []Scope[input, struct{}]{{ Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) { return commandkit.Acquired[[]configkit.Difference]{}, nil }, }}, } app := newConfigTestApp(t, Command(suite)) outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) if outcome.Kind != commandkit.OutcomeUsage || !errors.Is(outcome.Cause, commandkit.ErrFlagInput) { t.Fatalf("missing required selector = %#v", outcome) } if inputCalls != 1 || checkCalls != 0 || stateCalls != 0 { t.Fatalf("missing selector calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls) } } func TestComparisonHelpConvertsInapplicableSelectorsWithoutBuildingInputs(t *testing.T) { type input struct{ Tenant int } parseErr := errors.New("invalid tenant") var parseCalls, inputCalls, checkCalls, stateCalls int tenant := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant", Required: true}, (*int)(nil), func(raw string) (int, error) { parseCalls++ if raw == "bad" { return 0, parseErr } return strconv.Atoi(raw) }, func(value *input, supplied commandkit.Supplied[int]) { value.Tenant = supplied.Value }) makeScope := func(name string) Scope[input, struct{}] { return Scope[input, struct{}]{Name: name, Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) { return commandkit.Acquired[[]configkit.Difference]{}, nil }} } suite := Suite[input, struct{}]{ NewInput: func() input { inputCalls++; return input{} }, NewState: func() struct{} { stateCalls++; return struct{}{} }, Selectors: []Selector[input]{{Binding: tenant, Scopes: []string{"promptkit"}}}, CheckInput: func(string, Selection, input) error { checkCalls++; return nil }, Scopes: []Scope[input, struct{}]{makeScope("app"), makeScope("promptkit")}, } app := newConfigTestApp(t, Command(suite)) var output strings.Builder app.Execute(context.Background(), []string{"config", "diff", "--scope", "app", "--tenant=7", "--help"}, commandkit.Streams{Out: &output, Err: io.Discard}) if !strings.Contains(output.String(), "--tenant") || parseCalls != 1 { t.Fatalf("help did not convert the inapplicable supplied selector: calls=%d help=%q", parseCalls, output.String()) } outcome := app.Execute(context.Background(), []string{"config", "diff", "--scope", "app", "--tenant=bad", "--help"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) if outcome.Kind != commandkit.OutcomeUsage || !errors.Is(outcome.Cause, parseErr) || parseCalls != 2 { t.Fatalf("malformed help selector outcome=%#v conversions=%d", outcome, parseCalls) } if inputCalls != 0 || checkCalls != 0 || stateCalls != 0 { t.Fatalf("help built suite work: input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls) } }