Connect report rendering to invocation outcomes
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Shared reporting and PromptKit v0.20.0 implementation plan
|
# 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
|
## 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
|
## 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
|
Add `(*Reporter).Report(Report) error`. Delegate to Stage 2 through the existing
|
||||||
`RenderOutput` coordination and destination writer. Store the invocation format
|
`RenderOutput` coordination and destination writer. Store the invocation format
|
||||||
selection in reporting state; do not create another reporter or delivery state.
|
selection in reporting state; do not create another reporter or delivery state.
|
||||||
|
|||||||
19
reporter.go
19
reporter.go
@@ -41,6 +41,7 @@ type reportingState struct {
|
|||||||
diagnostics destinationState
|
diagnostics destinationState
|
||||||
closed bool
|
closed bool
|
||||||
mode reportingMode
|
mode reportingMode
|
||||||
|
format Format
|
||||||
progress bool
|
progress bool
|
||||||
progressSet bool
|
progressSet bool
|
||||||
deferred []deferredMessage
|
deferred []deferredMessage
|
||||||
@@ -68,10 +69,10 @@ type deferredMessage struct {
|
|||||||
// reenter the same reporter, and their supplied writer is valid only while the
|
// reenter the same reporter, and their supplied writer is valid only while the
|
||||||
// callback runs.
|
// callback runs.
|
||||||
//
|
//
|
||||||
// Consumers use Output for substantive command results and the reporting
|
// Consumers use Report for formatted substantive results, Output or
|
||||||
// methods for categorized messages. Delivery and rendering failures are
|
// RenderOutput for deliberate raw output, and the reporting methods for
|
||||||
// retained for Outcome.Presentation even when a caller ignores an immediate
|
// categorized messages. Delivery and rendering failures are retained for
|
||||||
// method's returned error.
|
// Outcome.Presentation even when a caller ignores an immediate method's error.
|
||||||
type Reporter struct {
|
type Reporter struct {
|
||||||
state *reportingState
|
state *reportingState
|
||||||
}
|
}
|
||||||
@@ -153,6 +154,16 @@ func (w managedOutputWriter) Write(p []byte) (int, error) {
|
|||||||
return w.state.write(w.destination, p)
|
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.
|
// RenderOutput coordinates a complete substantive report rendering operation.
|
||||||
// The callback receives a writer usable only during the callback and must not
|
// 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
|
// reenter this reporter. In quiet mode, the callback still runs against a
|
||||||
|
|||||||
88
reporter_report_test.go
Normal file
88
reporter_report_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user