diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c040c60..8c6b398 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Shared reporting and PromptKit v0.20.0 implementation plan -Status: implementation in progress; Stages 1–2 complete. +Status: implementation in progress; Stages 1–3 complete. ## Scope, authority, and execution @@ -137,6 +137,10 @@ semantic except where the selected text behavior itself matters. ## Stage 3: Reporter handoff through the existing lifecycle +**Complete.** Reporter handoff uses the existing delivery lifecycle. Focused +reporter tests and the root workspace suite (`GOTOOLCHAIN=local go test ./...` +with Go 1.27.0) passed. + Add `(*Reporter).Report(Report) error`. Delegate to Stage 2 through the existing `RenderOutput` coordination and destination writer. Store the invocation format selection in reporting state; do not create another reporter or delivery state. diff --git a/reporter.go b/reporter.go index 6237a2a..dc4cf32 100644 --- a/reporter.go +++ b/reporter.go @@ -41,6 +41,7 @@ type reportingState struct { diagnostics destinationState closed bool mode reportingMode + format Format progress bool progressSet bool deferred []deferredMessage @@ -68,10 +69,10 @@ type deferredMessage struct { // reenter the same reporter, and their supplied writer is valid only while the // callback runs. // -// Consumers use Output for substantive command results and the reporting -// methods for categorized messages. Delivery and rendering failures are -// retained for Outcome.Presentation even when a caller ignores an immediate -// method's returned error. +// Consumers use Report for formatted substantive results, Output or +// RenderOutput for deliberate raw output, and the reporting methods for +// categorized messages. Delivery and rendering failures are retained for +// Outcome.Presentation even when a caller ignores an immediate method's error. type Reporter struct { state *reportingState } @@ -153,6 +154,16 @@ func (w managedOutputWriter) Write(p []byte) (int, error) { return w.state.write(w.destination, p) } +// Report renders and delivers substantive content using the invocation's +// selected format. Rendering runs even when output is suppressed, and its +// failures are recorded separately from the operation outcome. Report must +// not be called from a rendering callback on the same reporter. +func (r *Reporter) Report(report Report) error { + return r.RenderOutput(func(w io.Writer) error { + return RenderReport(w, report, r.state.format) + }) +} + // RenderOutput coordinates a complete substantive report rendering operation. // The callback receives a writer usable only during the callback and must not // reenter this reporter. In quiet mode, the callback still runs against a diff --git a/reporter_report_test.go b/reporter_report_test.go new file mode 100644 index 0000000..aae645c --- /dev/null +++ b/reporter_report_test.go @@ -0,0 +1,88 @@ +package commandkit + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" +) + +func TestReporterReportQuietAndClosed(t *testing.T) { + var out bytes.Buffer + reporter := newReporter(Streams{Out: &out}) + reporter.state.mode = modeQuiet + err := reporter.Report(Report{Value: make(chan int)}) + if err == nil { + t.Fatal("expected serialization error") + } + outcome := reporter.finalize(Success(), nil) + if outcome.Presentation.Output.RenderError == nil || out.Len() != 0 { + t.Fatalf("outcome %#v, output %q", outcome, &out) + } + if err := reporter.Report(Report{Value: "late"}); !errors.Is(err, ErrReporterClosed) { + t.Fatalf("got %v", err) + } +} + +func TestReporterReportPreservesOperationAndDeliveryEvidence(t *testing.T) { + writeErr := errors.New("output unavailable") + cause := errors.New("domain cause") + for _, tc := range []struct { + name string + operation Outcome + want OutcomeKind + status int + }{ + {"success", Success(), OutcomeOperational, 1}, + {"domain", Domain("differences", 3).WithCause(cause), OutcomeDomain, 3}, + } { + t.Run(tc.name, func(t *testing.T) { + reporter := newReporter(Streams{Out: reportErrorWriter{writeErr}}) + if err := reporter.Report(Report{Value: "result"}); !errors.Is(err, writeErr) { + t.Fatalf("report error %v", err) + } + got := reporter.finalize(tc.operation, nil) + if got.Kind != tc.want || got.Status != tc.status || !errors.Is(got.Presentation.Output.WriteError, writeErr) { + t.Fatalf("outcome %#v", got) + } + if tc.name == "domain" && !errors.Is(got.Cause, cause) { + t.Fatalf("cause %#v", got.Cause) + } + }) + } +} + +func TestReporterReportAfterCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + command := NewCommand(CommandSpec[struct{}]{Name: "run", Run: func(inv Invocation, _ struct{}) Outcome { + cancel() + if err := inv.Reporter().Report(Report{Value: "acquired"}); err != nil { + t.Errorf("report: %v", err) + } + return Failure("canceled").WithCause(ctx.Err()) + }}) + app, err := New(Identity{Name: "Example"}, command) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + result := app.Execute(ctx, []string{"run"}, Streams{Out: &out, Err: io.Discard}) + if !strings.Contains(out.String(), "acquired") || !errors.Is(result.Cause, context.Canceled) { + t.Fatalf("outcome %#v, output %q", result, &out) + } +} + +func TestReporterUsesSelectedFormat(t *testing.T) { + var out bytes.Buffer + reporter := newReporter(Streams{Out: &out}) + reporter.state.format = FormatJSON + if err := reporter.Report(Report{Value: "value"}); err != nil { + t.Fatal(err) + } + if out.String() != "\"value\"\n" { + t.Fatalf("got %q", &out) + } +}