From 5f37aac70fcac52fd24065512c1f9b5513825914 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 26 Oct 2026 03:16:02 +0000 Subject: [PATCH] Render ConfigKit show and validate through reporter --- docs/roadmap/implementation.md | 13 +- integrations/ckconfig/family_reports_test.go | 117 +++++++++++++++++- integrations/ckconfig/operations.go | 51 ++++++-- integrations/ckconfig/show.go | 120 ++++++++----------- integrations/ckconfig/validate.go | 44 ++++--- 5 files changed, 235 insertions(+), 110 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9e4eca5..4cd829f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Invocation reporting implementation plan -Status: Stages 1–10 complete. Stage 11 is next. +Status: Stages 1–11 complete. Stage 12 is next. ## Objective and authority @@ -418,7 +418,7 @@ migration; Stage 20 will validate the assembled modules. ## Stage 11: Migrate configuration show and validate -Status: pending. +Status: complete. Primary files: [show.go](../../integrations/ckconfig/show.go), [validate.go](../../integrations/ckconfig/validate.go), and @@ -449,6 +449,15 @@ deferred context ordering, rendering failures, and report write failures. Update tests of superseded outcome fields to observable reports and `Presentation`, rather than dropping their original failure protection. +Configuration show and validate now submit provider messages through the shared +reporter and render results through managed output. Tests retain acquisition +ordering, shared state, YAML framing, cancellation with partial validation +output, and cover warning visibility, terminal context, quiet rendering, and +destination failures through `Presentation`. The ckconfig tests remain +unrunnable because `sources.go`, `explain.go`, `diff.go`, and family wiring and +tests still reference APIs scheduled for later migrations; Stage 20 will run the +assembled suites. + ## Stage 12: Migrate configuration sources, explain, and diff Status: pending. diff --git a/integrations/ckconfig/family_reports_test.go b/integrations/ckconfig/family_reports_test.go index 36335f3..5e01998 100644 --- a/integrations/ckconfig/family_reports_test.go +++ b/integrations/ckconfig/family_reports_test.go @@ -161,7 +161,7 @@ func TestValidationPreparesIndependentScopeInputsAndOneSharedState(t *testing.T) } app := newConfigTestApp(t, Command(suite)) var output, diagnostic bytes.Buffer - outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--config", "chosen.yaml", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: &diagnostic}) + outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--config", "chosen.yaml", "--campaign", "spring", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnostic}) if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(diagnostic.String(), "app warning") || !strings.Contains(diagnostic.String(), "promptkit warning") { t.Fatalf("validation outcome=%#v diagnostics=%q", outcome, diagnostic.String()) } @@ -289,7 +289,7 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) { makeScope("promptkit", "---\nname: promptkit\n", providerErr), }})) outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard}) - if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr || output.Len() != 0 { + if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr || output.Len() != 0 || outcome.Presentation.Output.State != commandkit.DeliveryNone { t.Fatalf("aggregate provider failure outcome=%#v output=%q", outcome, output.String()) } if len(acquisitionOutputLengths) != 2 || acquisitionOutputLengths[0] != 0 || acquisitionOutputLengths[1] != 0 { @@ -303,7 +303,7 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) { makeScope("promptkit", "name: promptkit\n", nil), }})) outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard}) - if outcome.Kind != commandkit.OutcomeSuccess { + if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliveryComplete { t.Fatalf("aggregate show outcome = %#v", outcome) } want := "---\n# scope: app\nname: app\n---\n# scope: promptkit\nname: promptkit\n" @@ -312,6 +312,109 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) { } } +func TestConfigShowSeparatesWarningsFromTerminalContextAcrossModes(t *testing.T) { + providerErr := errors.New("private provider cause") + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{ + Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + return commandkit.Acquired[View]{ + Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent warning")}, + Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error { + _, err := io.WriteString(writer, "native terminal context\n") + return err + }}}, + }, providerErr + }, + }}})) + + for _, test := range []struct { + name string + flags []string + wantWarning bool + wantTerminal bool + }{ + {name: "ordinary", wantTerminal: true}, + {name: "verbose", flags: []string{"--verbose"}, wantWarning: true, wantTerminal: true}, + {name: "quiet", flags: []string{"--quiet"}, wantTerminal: true}, + } { + t.Run(test.name, func(t *testing.T) { + args := append([]string{"config", "show"}, test.flags...) + var output, diagnostic bytes.Buffer + outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostic}) + if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr { + t.Fatalf("provider failure outcome = %#v", outcome) + } + if got := strings.Contains(diagnostic.String(), "independent warning"); got != test.wantWarning { + t.Fatalf("warning visibility = %v, output %q", got, diagnostic.String()) + } + if got := strings.Contains(diagnostic.String(), "native terminal context"); got != test.wantTerminal { + t.Fatalf("terminal context visibility = %v, output %q", got, diagnostic.String()) + } + if strings.Contains(diagnostic.String(), providerErr.Error()) { + t.Fatalf("cause text was duplicated into presentation: %q", diagnostic.String()) + } + if test.wantWarning && strings.Index(diagnostic.String(), "independent warning") > strings.Index(diagnostic.String(), "native terminal context") { + t.Fatalf("deferred warning did not precede terminal context: %q", diagnostic.String()) + } + }) + } +} + +func TestConfigShowAndValidationUseManagedRendering(t *testing.T) { + t.Run("quiet show still records native render errors", func(t *testing.T) { + renderErr := errors.New("native renderer failed") + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{ + Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + return commandkit.Acquired[View]{Value: View{Effective: failedEffective{err: renderErr}}}, nil + }, + }}})) + var output, diagnostic bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "show", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnostic}) + if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || outcome.Cause != nil || output.Len() != 0 { + t.Fatalf("quiet render failure outcome=%#v output=%q", outcome, output.String()) + } + }) + + for _, operation := range []Operation{Show, Validate} { + t.Run(string(operation)+" write failure", func(t *testing.T) { + writeErr := errors.New("report destination failed") + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{ + Name: "app", + Operations: []Operation{operation}, + Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("value: ready\n")}}, nil + }, + }}})) + commandName := "show" + if operation == Validate { + commandName = "validate" + } + outcome := app.Execute(context.Background(), []string{"config", commandName}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Cause != nil { + t.Fatalf("report write failure outcome = %#v", outcome) + } + }) + + t.Run(string(operation)+" quiet suppression", func(t *testing.T) { + writeErr := errors.New("quiet destination should not be used") + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{ + Name: "app", + Operations: []Operation{operation}, + Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("value: ready\n")}}, nil + }, + }}})) + commandName := "show" + if operation == Validate { + commandName = "validate" + } + outcome := app.Execute(context.Background(), []string{"config", commandName, "--quiet"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliverySuppressed || outcome.Presentation.Output.WriteError != nil || outcome.Cause != nil { + t.Fatalf("quiet report outcome = %#v", outcome) + } + }) + } +} + func TestSourcesReadsNativeInventoryOnceAndPreservesZeroValues(t *testing.T) { reader := &sourceInventory{values: []configkit.SourceInfo{{ ID: "synthetic", Label: "", Path: "", Layer: configkit.Layer("base"), Kind: configkit.SourceKind("mapping"), UnderlayIndex: 0, @@ -394,7 +497,7 @@ func TestReportWriteFailureTracksIncompleteDeliveryAndCancellation(t *testing.T) }})) var output bytes.Buffer outcome = app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard}) - if !errors.Is(outcome.Cause, context.Canceled) || !outcome.ReportIncomplete || calls != 2 || output.String() != "app: valid\n" { + if !errors.Is(outcome.Cause, context.Canceled) || outcome.Presentation.Output.State != commandkit.DeliveryComplete || calls != 2 || output.String() != "app: valid\n" { t.Fatalf("cancelled aggregate validation outcome=%#v calls=%d output=%q", outcome, calls, output.String()) } } @@ -406,6 +509,12 @@ func (value effectiveValue) WriteEffective(writer io.Writer) error { return err } +type failedEffective struct{ err error } + +func (value failedEffective) WriteEffective(io.Writer) error { + return value.err +} + type sourceInventory struct { values []configkit.SourceInfo calls int diff --git a/integrations/ckconfig/operations.go b/integrations/ckconfig/operations.go index a972ffa..dcdaee9 100644 --- a/integrations/ckconfig/operations.go +++ b/integrations/ckconfig/operations.go @@ -1,7 +1,6 @@ package ckconfig import ( - "errors" "fmt" commandkit "gitea.maximumdirect.net/eric/CommandKit" @@ -9,10 +8,10 @@ import ( func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Operation, input *configCommandInput[I, S]) commandkit.Outcome { if input == nil || input.plan == nil { - return commandkit.Failure(errors.New("configuration operation was not prepared")) + return commandkit.Failure("Configuration operation was not prepared.") } if err := invocation.Context.Err(); err != nil { - return commandkit.Failure(err) + return configFailure("Configuration operation was canceled.", commandkit.Explanation{}, err) } plan := input.plan var state S @@ -31,19 +30,47 @@ func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Op case Diff: return runConfigDiff(invocation, plan, state) default: - return commandkit.Failure(fmt.Errorf("unsupported configuration operation %q", operation)) + return commandkit.Failure(fmt.Sprintf("Unsupported configuration operation %q.", operation)) } } -func configOperationFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome { - outcome := commandkit.Failure(cause) - outcome.Diagnostics = diagnostics - outcome.ReportIncomplete = reportStarted +func queueConfigDiagnostics(reporter *commandkit.Reporter, diagnostics []commandkit.Diagnostic) { + deferred := reporter.AtEnd() + for _, diagnostic := range diagnostics { + if diagnostic.Write == nil { + continue + } + switch diagnostic.Severity { + case commandkit.SeverityInfo: + _ = deferred.Info("", diagnostic.Write) + case commandkit.SeverityDebug: + _ = deferred.Debug("", diagnostic.Write) + case commandkit.SeverityProgress: + _ = reporter.Progress("", diagnostic.Write) + default: + _ = deferred.Warn("", diagnostic.Write) + } + } +} + +func configFailure(message string, explanation commandkit.Explanation, cause error) commandkit.Outcome { + if explanation.Message != "" || len(explanation.Details) > 0 { + message = explanation.Message + } + outcome := commandkit.Failure(message, explanation.Details...) + if cause != nil { + outcome = outcome.WithCause(cause) + } return outcome } -func configDeliveryFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome { - outcome := configOperationFailure(cause, diagnostics, reportStarted) - outcome.DeliveryError = cause - return outcome +func configAcquisitionFailure( + reporter *commandkit.Reporter, + operation, scope string, + acquired commandkit.Acquired[View], + cause error, +) commandkit.Outcome { + queueConfigDiagnostics(reporter, acquired.Diagnostics) + message := fmt.Sprintf("Unable to %s configuration scope %q.", operation, scope) + return configFailure(message, acquired.Explanation, cause) } diff --git a/integrations/ckconfig/show.go b/integrations/ckconfig/show.go index 5cc02f7..fd17788 100644 --- a/integrations/ckconfig/show.go +++ b/integrations/ckconfig/show.go @@ -2,7 +2,6 @@ package ckconfig import ( "bytes" - "errors" "fmt" "io" "strconv" @@ -18,101 +17,76 @@ type effectiveDocument struct { func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { documents := make([]effectiveDocument, 0, len(plan.scopes)) - diagnostics := make([]commandkit.Diagnostic, 0) + reporter := invocation.Reporter() for _, selected := range plan.scopes { if err := invocation.Context.Err(); err != nil { - return configOperationFailure(err, diagnostics, false) + return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err) } if selected.scope.scope.Acquire == nil { - return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", selected.scope.scope.Name), diagnostics, false) + message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name) + return commandkit.Failure(message) } acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ Operation: Show, Selection: plan.selection, }, selected.input, state) - diagnostics = append(diagnostics, acquired.Diagnostics...) if acquireErr != nil { - return configOperationFailure(acquireErr, diagnostics, false) + return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr) } + queueConfigDiagnostics(reporter, acquired.Diagnostics) if err := invocation.Context.Err(); err != nil { - return configOperationFailure(err, diagnostics, false) + return configFailure("Configuration show was canceled.", acquired.Explanation, err) } if acquired.Value.Effective == nil { - return configOperationFailure(fmt.Errorf("configuration scope %q did not provide an effective configuration writer", selected.scope.scope.Name), diagnostics, false) + message := fmt.Sprintf("Configuration scope %q did not provide an effective configuration writer.", selected.scope.scope.Name) + return commandkit.Failure(message) } documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value}) } if err := invocation.Context.Err(); err != nil { - return configOperationFailure(err, diagnostics, false) + return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err) } if len(documents) == 1 && plan.scopeName != "all" { - checked := &checkedWriter{writer: invocation.Streams.Out} - renderErr := documents[0].view.Effective.WriteEffective(checked) - if checked.failure != nil { - if renderErr == nil { - renderErr = checked.failure - } else { - renderErr = errors.Join(renderErr, checked.failure) + err := reporter.RenderOutput(func(writer io.Writer) error { + return documents[0].view.Effective.WriteEffective(writer) + }) + if err != nil && invocation.Context.Err() != nil { + return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err()) + } + return commandkit.Success() + } + + err := reporter.RenderOutput(func(writer io.Writer) error { + for index, document := range documents { + if err := invocation.Context.Err(); err != nil { + return err + } + var native bytes.Buffer + if err := document.view.Effective.WriteEffective(&native); err != nil { + return err + } + if err := invocation.Context.Err(); err != nil { + return err + } + framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes()) + if index > 0 && hasDirective { + if err := writeConfigReport(writer, []byte("...\n")); err != nil { + return err + } + } + if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' { + framed = append(framed, '\n') + } + if err := writeConfigReport(writer, framed); err != nil { + return err } } - if renderErr != nil { - outcome := configOperationFailure(renderErr, diagnostics, true) - outcome.DeliveryError = checked.failure - return outcome - } - outcome := commandkit.Success() - outcome.Diagnostics = diagnostics - return outcome + return nil + }) + if err != nil && invocation.Context.Err() != nil { + return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err()) } - - written := false - for index, document := range documents { - if err := invocation.Context.Err(); err != nil { - return configOperationFailure(err, diagnostics, written) - } - var native bytes.Buffer - if err := document.view.Effective.WriteEffective(&native); err != nil { - return configOperationFailure(err, diagnostics, written) - } - if err := invocation.Context.Err(); err != nil { - return configOperationFailure(err, diagnostics, written) - } - framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes()) - if index > 0 && hasDirective { - if err := writeConfigReport(invocation.Streams.Out, []byte("...\n")); err != nil { - return configDeliveryFailure(err, diagnostics, true) - } - } - if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' { - framed = append(framed, '\n') - } - if err := writeConfigReport(invocation.Streams.Out, framed); err != nil { - return configDeliveryFailure(err, diagnostics, true) - } - written = true - } - outcome := commandkit.Success() - outcome.Diagnostics = diagnostics - return outcome -} - -type checkedWriter struct { - writer io.Writer - failure error -} - -func (writer *checkedWriter) Write(content []byte) (int, error) { - if writer.failure != nil { - return 0, writer.failure - } - written, err := writer.writer.Write(content) - if err == nil && written != len(content) { - err = io.ErrShortWrite - } - if err != nil { - writer.failure = err - } - return written, err + return commandkit.Success() } type yamlPreamble struct { diff --git a/integrations/ckconfig/validate.go b/integrations/ckconfig/validate.go index bfdb69b..fbc21f3 100644 --- a/integrations/ckconfig/validate.go +++ b/integrations/ckconfig/validate.go @@ -3,6 +3,7 @@ package ckconfig import ( "bytes" "fmt" + "io" "strings" commandkit "gitea.maximumdirect.net/eric/CommandKit" @@ -10,44 +11,49 @@ import ( ) func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { - diagnostics := make([]commandkit.Diagnostic, 0) + reporter := invocation.Reporter() writtenRecord := false for _, selected := range plan.scopes { if err := invocation.Context.Err(); err != nil { - return configOperationFailure(err, diagnostics, writtenRecord) + return configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err) } if selected.scope.scope.Acquire == nil { - return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", selected.scope.scope.Name), diagnostics, writtenRecord) + message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name) + return commandkit.Failure(message) } acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ Operation: Validate, Selection: plan.selection, }, selected.input, state) - diagnostics = append(diagnostics, acquired.Diagnostics...) if acquireErr != nil { - return configOperationFailure(acquireErr, diagnostics, writtenRecord) + return configAcquisitionFailure(reporter, "validate", selected.scope.scope.Name, acquired, acquireErr) } + queueConfigDiagnostics(reporter, acquired.Diagnostics) if err := invocation.Context.Err(); err != nil { - return configOperationFailure(err, diagnostics, writtenRecord) + return configFailure("Configuration validation was canceled.", acquired.Explanation, err) } - record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format) - if err != nil { - return configOperationFailure(err, diagnostics, writtenRecord) - } - if plan.format == "yaml" && !writtenRecord { - if err := writeConfigReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil { - return configDeliveryFailure(err, diagnostics, true) + err := reporter.RenderOutput(func(writer io.Writer) error { + record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format) + if err != nil { + return err } - } - if err := writeConfigReport(invocation.Streams.Out, record); err != nil { - return configDeliveryFailure(err, diagnostics, true) + if plan.format == "yaml" && !writtenRecord { + if err := writeConfigReport(writer, []byte("scopes:\n")); err != nil { + return err + } + } + return writeConfigReport(writer, record) + }) + if err != nil { + if ctxErr := invocation.Context.Err(); ctxErr != nil { + return configFailure("Configuration validation was canceled.", acquired.Explanation, ctxErr) + } + return commandkit.Success() } writtenRecord = true } - outcome := commandkit.Success() - outcome.Diagnostics = diagnostics - return outcome + return commandkit.Success() } func validationSuccessRecord(scope, format string) ([]byte, error) {