diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5333918..50bb345 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement ConfigKit adoption and configuration comparison -Status: implementation in progress; Stages 1–7 are complete. +Status: implementation in progress; Stages 1–8 are complete. ## Scope and authority @@ -404,12 +404,20 @@ the writer error and the existing incomplete-report indication. Diagnostic delivery itself continues through core, including its established writer-failure handling. Do not manually write the same diagnostics from the handler. -**Completion evidence:** default/explicit YAML, text rejection, file/overlay -selection inheritance, comparison-only scope, discovery/help availability, and -unsupported scopes with zero provider work. Cover provider diagnostics with an -error, wrapped identity, report preparation failure, cancellation, output/short -writes, and repeated invocations with fresh inputs/state. Existing four operations -and their output defaults must continue to work. +**Completion evidence — complete:** the installed `config diff` dispatches through +the prepared comparison pair and its provider once, never through ordinary +acquisition. Execution preserves provider diagnostics and causes, checks +cancellation before writing, renders the full report before delivery, and uses +the existing operational/delivery outcomes. Public core/Cobra execution tests +cover default and explicit YAML, text rejection before provider work, right-side +configuration with inherited overlay selection, comparison-only discovery and +execution, and unsupported scopes before factories or providers. They also cover +provider errors with diagnostics and wrapped identity, malformed report snapshots, +cancellation after acquisition, output errors and short writes, and repeated +invocations with distinct side inputs and fresh shared state. A real ConfigKit +consumer comparison covers concealed changes and empty results. Existing +operations remain covered by the ckconfig suite. `go test -count=1 ./...` passes +in ckconfig and ckp. ## Stage 9: Demonstrate the simple consumer comparison diff --git a/integrations/ckconfig/diff.go b/integrations/ckconfig/diff.go new file mode 100644 index 0000000..639b6b6 --- /dev/null +++ b/integrations/ckconfig/diff.go @@ -0,0 +1,43 @@ +package ckconfig + +import ( + "errors" + "fmt" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" +) + +func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { + if plan.comparison == nil || plan.comparison.scope == nil { + return configOperationFailure(errors.New("configuration comparison was not prepared"), nil, false) + } + selected := plan.comparison + if err := invocation.Context.Err(); err != nil { + return configOperationFailure(err, nil, false) + } + compare := selected.scope.scope.Compare + if compare == nil { + return configOperationFailure(fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name), nil, false) + } + acquired, err := compare(invocation.Context, selected.request, state) + diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...) + if err != nil { + return configOperationFailure(err, diagnostics, false) + } + if err := invocation.Context.Err(); err != nil { + return configOperationFailure(err, diagnostics, false) + } + report, err := renderDiffReport(acquired.Value) + if err != nil { + return configOperationFailure(err, diagnostics, false) + } + if err := invocation.Context.Err(); err != nil { + return configOperationFailure(err, diagnostics, false) + } + if err := writeConfigReport(invocation.Streams.Out, report); err != nil { + return configDeliveryFailure(err, diagnostics, true) + } + outcome := commandkit.Success() + outcome.Diagnostics = diagnostics + return outcome +} diff --git a/integrations/ckconfig/diff_execution_test.go b/integrations/ckconfig/diff_execution_test.go new file mode 100644 index 0000000..37b4a8e --- /dev/null +++ b/integrations/ckconfig/diff_execution_test.go @@ -0,0 +1,256 @@ +package ckconfig + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + "testing" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "go.yaml.in/yaml/v4" +) + +func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *testing.T) { + type settings struct { + Greeting string `yaml:"greeting"` + } + var selections []Selection + app := newConfigTestApp(t, AppCommand(func(_ context.Context, selection Selection) (*configkit.Result[settings], error) { + selections = append(selections, selection) + value := "Hello" + if selection.Config.Set && selection.Config.Value == "after.yml" { + value = "Goodbye" + } + return configkit.Load(configkit.Mapping("selected configuration", map[string]any{"greeting": value}), configkit.Options[settings]{}) + })) + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before.yml", "--overlay", "base", "--against-config", "after.yml"}, commandkit.Streams{Out: &output, Err: io.Discard}) + 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 { + t.Fatalf("comparison selections = %#v", selections) + } + var document yaml.Node + if err := yaml.Unmarshal(output.Bytes(), &document); err != nil { + t.Fatalf("default report is not YAML: %q: %v", output.String(), err) + } + changes := reportField(document.Content[0], "changes") + if value := reportField(changes, "greeting"); value == nil || value.Value != "Goodbye" { + t.Fatalf("public comparison output = %#v", document.Content[0]) + } + + output.Reset() + outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "yaml", "--config", "before.yml", "--against-config", "before.yml"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("explicit YAML comparison outcome = %#v", outcome) + } + var equal yaml.Node + if err := yaml.Unmarshal(output.Bytes(), &equal); err != nil { + t.Fatalf("explicit YAML report is not YAML: %q: %v", output.String(), err) + } + if len(reportField(equal.Content[0], "additions").Content) != 0 || len(reportField(equal.Content[0], "changes").Content) != 0 || len(reportField(equal.Content[0], "deletions").Content) != 0 { + t.Fatalf("equal comparison did not return empty categories: %q", output.String()) + } + + loadsBeforeText := len(selections) + output.Reset() + outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "text"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeUsage || len(selections) != loadsBeforeText || output.Len() != 0 { + t.Fatalf("text format outcome/work/output = %#v / %d / %q", outcome, len(selections)-loadsBeforeText, output.String()) + } +} + +func TestConfigDiffRunsComparisonOnlyScopeAndRejectsUnsupportedScopeBeforeWork(t *testing.T) { + var inputCalls, stateCalls, acquireCalls, compareCalls int + app := newConfigTestApp(t, Command(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{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + acquireCalls++ + return commandkit.Acquired[View]{}, nil + }}, + }, + })) + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "app: diff") { + t.Fatalf("comparison-only discovery = %#v, %q", outcome, output.String()) + } + output.Reset() + outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "app"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || compareCalls != 1 { + t.Fatalf("comparison-only execution = %#v calls=%d", outcome, compareCalls) + } + inputsBefore, statesBefore := inputCalls, stateCalls + output.Reset() + outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "plain"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeUsage || inputCalls != inputsBefore || stateCalls != statesBefore || acquireCalls != 0 || compareCalls != 1 || output.Len() != 0 { + t.Fatalf("unsupported scope outcome/work/output = %#v / %d,%d,%d,%d / %q", outcome, inputCalls, stateCalls, acquireCalls, compareCalls, output.String()) + } +} + +func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) { + wantErr := errors.New("provider unavailable") + diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error { + _, err := io.WriteString(writer, "provider warning\n") + return err + }} + app := newConfigTestApp(t, Command(Suite[struct{}, 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]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, fmt.Errorf("comparison setup: %w", wantErr) + }, + }}})) + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityError { + t.Fatalf("provider failure outcome/output = %#v / %q", outcome, output.String()) + } +} + +func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) { + app := newConfigTestApp(t, Command(Suite[struct{}, 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]{Value: []configkit.Difference{{ + Path: configkit.Keys("invalid"), Right: configkit.ValueSnapshot{Present: true, YAML: "bad: ["}, + }}}, nil + }, + }}})) + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeOperational || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 { + t.Fatalf("report preparation failure outcome/output = %#v / %q", outcome, output.String()) + } +} + +func TestConfigDiffCancellationAfterAcquisitionRetainsDiagnosticsWithoutWriting(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(io.Writer) error { return nil }} + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{ + Name: "app", Operations: []Operation{Diff}, + Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) { + cancel() + return commandkit.Acquired[[]configkit.Difference]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil + }, + }}})) + defer cancel() + var output bytes.Buffer + outcome := app.Execute(ctx, []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || outcome.ReportIncomplete || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityWarning { + t.Fatalf("cancelled comparison outcome/output = %#v / %q", outcome, output.String()) + } +} + +func TestConfigDiffOutputErrorsCarryIncompleteDeliveryOutcome(t *testing.T) { + app := newConfigTestApp(t, Command(Suite[struct{}, 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 + }, + }}})) + wantErr := errors.New("output failed") + for _, test := range []struct { + name string + writer io.Writer + want error + }{ + {name: "writer error", writer: failingOutput{err: wantErr}, want: wantErr}, + {name: "short write", writer: shortWriter{}, want: io.ErrShortWrite}, + } { + t.Run(test.name, func(t *testing.T) { + outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: test.writer, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, test.want) || !errors.Is(outcome.Cause, test.want) || !outcome.ReportIncomplete { + t.Fatalf("delivery failure outcome = %#v", outcome) + } + }) + } +} + +func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) { + type input struct { + ID int + Label string + } + type state struct{ ID int } + label := commandkit.ValueFlag(commandkit.FlagMeta{Name: "label"}, (*string)(nil), parseConfigString, + func(target *input, value commandkit.Supplied[string]) { target.Label = value.Value }) + var inputID, stateID int + var observed []struct { + left, right input + state *state + } + app := newConfigTestApp(t, Command(Suite[input, *state]{ + NewInput: func() input { inputID++; return input{ID: inputID} }, + NewState: func() *state { stateID++; return &state{ID: stateID} }, + Selectors: []Selector[input]{{Binding: label, Scopes: []string{"app"}}}, + Scopes: []Scope[input, *state]{{Name: "app", Operations: []Operation{Diff}, Compare: func(_ context.Context, request ComparisonRequest[input], shared *state) (commandkit.Acquired[[]configkit.Difference], error) { + observed = append(observed, struct { + left, right input + state *state + }{request.Left.Input, request.Right.Input, shared}) + left, err := configkit.Load[struct { + Label string `yaml:"label"` + }](configkit.Mapping("left", map[string]any{"label": request.Left.Input.Label})) + if err != nil { + return commandkit.Acquired[[]configkit.Difference]{}, err + } + right, err := configkit.Load[struct { + Label string `yaml:"label"` + }](configkit.Mapping("right", map[string]any{"label": request.Right.Input.Label + "-right"})) + if err != nil { + return commandkit.Acquired[[]configkit.Difference]{}, err + } + differences, err := configkit.Diff(left, right) + return commandkit.Acquired[[]configkit.Difference]{Value: differences}, err + }}}, + })) + for range 2 { + outcome := app.Execute(context.Background(), []string{"config", "diff", "--label", "current"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("comparison invocation outcome = %#v", outcome) + } + } + 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" { + 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 { + t.Fatalf("state was not fresh per invocation: %#v", observed) + } +} + +func TestConfigDiffExecutesConcealedChangesAndEmptyResults(t *testing.T) { + type settings struct { + Secret string `yaml:"secret"` + } + app := newConfigTestApp(t, AppCommand(func(_ context.Context, selection Selection) (*configkit.Result[settings], error) { + value := "same" + if selection.Config.Set && selection.Config.Value == "after" { + value = "changed-secret" + } + return configkit.Load(configkit.Mapping("selected", map[string]any{"secret": value}), configkit.Options[settings]{Definition: configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}}) + })) + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "after"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "[REDACTED]") || strings.Contains(output.String(), "changed-secret") { + t.Fatalf("concealed comparison outcome/report = %#v / %q", outcome, output.String()) + } + output.Reset() + outcome = app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "before"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "additions:") || !strings.Contains(output.String(), "changes:") || !strings.Contains(output.String(), "deletions:") { + t.Fatalf("empty comparison outcome/report = %#v / %q", outcome, output.String()) + } +} diff --git a/integrations/ckconfig/operations.go b/integrations/ckconfig/operations.go index cb5ca98..a972ffa 100644 --- a/integrations/ckconfig/operations.go +++ b/integrations/ckconfig/operations.go @@ -28,6 +28,8 @@ func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Op return runConfigSources(invocation, plan, state) case Explain: return runConfigExplanation(invocation, plan, state) + case Diff: + return runConfigDiff(invocation, plan, state) default: return commandkit.Failure(fmt.Errorf("unsupported configuration operation %q", operation)) }