diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5f3df16..c4322a5 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -102,7 +102,7 @@ callers are migrated in later stages. ## Stage 2: Implement reporter state and managed output writers -Status: pending. +Status: complete. Primary files: new private reporting implementation in core, plus [identity.go](../../identity.go) as needed for internal wiring. diff --git a/execute.go b/execute.go index 0ca7120..15efbcd 100644 --- a/execute.go +++ b/execute.go @@ -215,16 +215,6 @@ func (current *execution) routeUsageLine() string { return line + "\nAlias for: " + canonical } -func normalizeStreams(streams Streams) Streams { - if streams.Out == nil { - streams.Out = io.Discard - } - if streams.Err == nil { - streams.Err = io.Discard - } - return streams -} - func (a *App) present(outcome Outcome, path []string, streams Streams) Outcome { return a.presentWithUsageLine(outcome, path, streams, a.usageLine) } diff --git a/identity.go b/identity.go index 2095d9e..e155243 100644 --- a/identity.go +++ b/identity.go @@ -27,6 +27,16 @@ type Streams struct { Err io.Writer } +func normalizeStreams(streams Streams) Streams { + if streams.Out == nil { + streams.Out = io.Discard + } + if streams.Err == nil { + streams.Err = io.Discard + } + return streams +} + // Invocation provides execution context and output streams to a command // handler. For command aliases, EnteredPath and CanonicalPath expose the route // the caller used and the selected canonical command path. Both omit the diff --git a/reporter.go b/reporter.go new file mode 100644 index 0000000..4890c6f --- /dev/null +++ b/reporter.go @@ -0,0 +1,186 @@ +package commandkit + +import ( + "io" + "sync" +) + +type reportingMode uint8 + +const ( + modeOrdinary reportingMode = iota + modeQuiet + modeVerbose + modeDebug +) + +type reportingDestination uint8 + +const ( + destinationOutput reportingDestination = iota + destinationDiagnostics +) + +type reportingState struct { + mu sync.Mutex + output destinationState + diagnostics destinationState + closed bool + mode reportingMode + progress bool + progressSet bool +} + +type destinationState struct { + writer io.Writer + submitted bool + delivered bool + suppressed bool + renderError error + writeError error +} + +// Reporter coordinates presentation and delivery for one invocation. +// +// The invocation supplies the reporter. Consumers use Output for substantive +// command results and the reporting methods for categorized messages. +type Reporter struct { + state *reportingState +} + +type managedOutputWriter struct { + state *reportingState + destination reportingDestination +} + +func newReporter(streams Streams) *Reporter { + streams = normalizeStreams(streams) + return &Reporter{state: &reportingState{ + output: destinationState{writer: streams.Out}, + diagnostics: destinationState{writer: streams.Err}, + mode: modeOrdinary, + }} +} + +// Output returns a managed writer for substantive command results. +func (r *Reporter) Output() io.Writer { + if r == nil || r.state == nil { + return managedOutputWriter{} + } + return managedOutputWriter{state: r.state, destination: destinationOutput} +} + +func (w managedOutputWriter) Write(p []byte) (int, error) { + if w.state == nil { + return 0, ErrReporterClosed + } + return w.state.write(w.destination, p) +} + +func (state *reportingState) write(destination reportingDestination, p []byte) (int, error) { + state.mu.Lock() + defer state.mu.Unlock() + return state.writeLocked(destination, p) +} + +// writeLocked is used by reporter operations that already hold state.mu. +func (state *reportingState) writeLocked(destination reportingDestination, p []byte) (int, error) { + if state.closed { + return 0, ErrReporterClosed + } + + current := state.destination(destination) + current.submitted = true + if state.suppressed(destination) { + current.suppressed = true + return len(p), nil + } + if current.writeError != nil { + return 0, current.writeError + } + + n, err := current.writer.Write(p) + if err == nil && n < len(p) { + err = io.ErrShortWrite + } + if err != nil && current.writeError == nil { + current.writeError = err + } + if err == nil { + current.delivered = true + } + return n, err +} + +func (state *reportingState) destination(destination reportingDestination) *destinationState { + if destination == destinationDiagnostics { + return &state.diagnostics + } + return &state.output +} + +func (state *reportingState) suppressed(destination reportingDestination) bool { + if destination == destinationOutput { + return state.mode == modeQuiet + } + return false +} + +func (state *reportingState) recordRenderErrorLocked(destination reportingDestination, err error) { + if err == nil { + return + } + current := state.destination(destination) + if current.renderError == nil { + current.renderError = err + } +} + +func (state *reportingState) presentationLocked() PresentationResult { + return PresentationResult{ + Output: state.deliveryResultLocked(destinationOutput), + Diagnostics: state.deliveryResultLocked(destinationDiagnostics), + } +} + +func (state *reportingState) presentation() PresentationResult { + state.mu.Lock() + defer state.mu.Unlock() + return state.presentationLocked() +} + +func (state *reportingState) deliveryResultLocked(destination reportingDestination) DeliveryResult { + current := state.destination(destination) + result := DeliveryResult{RenderError: current.renderError, WriteError: current.writeError} + switch { + case current.renderError != nil || current.writeError != nil: + result.State = DeliveryIncomplete + case !current.submitted: + result.State = DeliveryNone + case current.delivered: + result.State = DeliveryComplete + case current.suppressed: + result.State = DeliverySuppressed + } + return result +} + +func (r *Reporter) setVisibility(mode reportingMode, progress bool, progressSet bool) { + if r == nil || r.state == nil { + return + } + r.state.mu.Lock() + r.state.mode = mode + r.state.progress = progress + r.state.progressSet = progressSet + r.state.mu.Unlock() +} + +func (r *Reporter) close() { + if r == nil || r.state == nil { + return + } + r.state.mu.Lock() + r.state.closed = true + r.state.mu.Unlock() +} diff --git a/reporter_test.go b/reporter_test.go new file mode 100644 index 0000000..d4af00c --- /dev/null +++ b/reporter_test.go @@ -0,0 +1,174 @@ +package commandkit + +import ( + "bytes" + "errors" + "io" + "testing" +) + +type reporterTestWriter struct { + writes int + n int + err error + output bytes.Buffer +} + +func (w *reporterTestWriter) Write(p []byte) (int, error) { + w.writes++ + if w.err != nil || w.n < len(p) { + n := w.n + if n > len(p) { + n = len(p) + } + if n > 0 { + _, _ = w.output.Write(p[:n]) + } + return n, w.err + } + return w.output.Write(p) +} + +func TestReporterOutputSubmitsOnWrite(t *testing.T) { + var destination bytes.Buffer + reporter := newReporter(Streams{Out: &destination}) + writer := reporter.Output() + + if got := reporter.state.presentation().Output.State; got != DeliveryNone { + t.Fatalf("state after obtaining output = %v, want no delivery", got) + } + if n, err := writer.Write([]byte("report")); err != nil || n != len("report") { + t.Fatalf("Write() = %d, %v", n, err) + } + if destination.String() != "report" { + t.Fatalf("output = %q", destination.String()) + } + if got := reporter.state.presentation().Output.State; got != DeliveryComplete { + t.Fatalf("output state = %v, want complete", got) + } +} + +func TestReporterQuietOutputDiscardsSuccessfully(t *testing.T) { + var destination bytes.Buffer + reporter := newReporter(Streams{Out: &destination}) + reporter.setVisibility(modeQuiet, false, false) + writer := reporter.Output() + + if n, err := writer.Write([]byte("hidden")); err != nil || n != len("hidden") { + t.Fatalf("quiet Write() = %d, %v", n, err) + } + if destination.Len() != 0 { + t.Fatalf("quiet output reached destination: %q", destination.String()) + } + if got := reporter.state.presentation().Output.State; got != DeliverySuppressed { + t.Fatalf("output state = %v, want suppressed", got) + } +} + +func TestReporterRetainsFirstWriteFailureAndStopsUsingDestination(t *testing.T) { + first := errors.New("first write failed") + second := errors.New("later write failed") + destination := &reporterTestWriter{n: 1, err: first} + reporter := newReporter(Streams{Out: destination}) + writer := reporter.Output() + + if n, err := writer.Write([]byte("first")); n != 1 || !errors.Is(err, first) { + t.Fatalf("first Write() = %d, %v", n, err) + } + if n, err := writer.Write([]byte("second")); n != 0 || !errors.Is(err, first) || errors.Is(err, second) { + t.Fatalf("second Write() = %d, %v, want retained first error", n, err) + } + if destination.writes != 1 { + t.Fatalf("destination writes = %d, want 1", destination.writes) + } + result := reporter.state.presentation().Output + if result.State != DeliveryIncomplete || !errors.Is(result.WriteError, first) { + t.Fatalf("delivery result = %#v", result) + } +} + +func TestReporterNormalizesShortWriteAndRetainsIgnoredFailure(t *testing.T) { + destination := &reporterTestWriter{n: 2} + reporter := newReporter(Streams{Out: destination}) + _, _ = reporter.Output().Write([]byte("short")) + + result := reporter.state.presentation().Output + if result.State != DeliveryIncomplete || !errors.Is(result.WriteError, io.ErrShortWrite) { + t.Fatalf("delivery result = %#v, want short-write failure", result) + } +} + +func TestReporterRetainsFirstRenderFailurePerDestination(t *testing.T) { + firstOutputErr := errors.New("first output render failed") + secondOutputErr := errors.New("later output render failed") + diagnosticErr := errors.New("diagnostic render failed") + reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard}) + + reporter.state.mu.Lock() + reporter.state.recordRenderErrorLocked(destinationOutput, firstOutputErr) + reporter.state.recordRenderErrorLocked(destinationOutput, secondOutputErr) + reporter.state.recordRenderErrorLocked(destinationDiagnostics, diagnosticErr) + reporter.state.mu.Unlock() + + result := reporter.state.presentation() + if !errors.Is(result.Output.RenderError, firstOutputErr) || errors.Is(result.Output.RenderError, secondOutputErr) { + t.Fatalf("output render error = %v, want first error only", result.Output.RenderError) + } + if !errors.Is(result.Diagnostics.RenderError, diagnosticErr) { + t.Fatalf("diagnostic render error = %v", result.Diagnostics.RenderError) + } + if result.Output.State != DeliveryIncomplete || result.Diagnostics.State != DeliveryIncomplete { + t.Fatalf("render failures were not incomplete: %#v", result) + } +} + +func TestReporterDestinationsTrackFailuresIndependently(t *testing.T) { + outputErr := errors.New("output failed") + output := &reporterTestWriter{n: 1, err: outputErr} + var diagnostics bytes.Buffer + reporter := newReporter(Streams{Out: output, Err: &diagnostics}) + + _, _ = reporter.Output().Write([]byte("out")) + if n, err := reporter.state.write(destinationDiagnostics, []byte("warning")); err != nil || n != len("warning") { + t.Fatalf("diagnostic Write() = %d, %v", n, err) + } + if diagnostics.String() != "warning" { + t.Fatalf("diagnostic output = %q", diagnostics.String()) + } + result := reporter.state.presentation() + if result.Output.State != DeliveryIncomplete || !errors.Is(result.Output.WriteError, outputErr) { + t.Fatalf("output result = %#v", result.Output) + } + if result.Diagnostics.State != DeliveryComplete || result.Diagnostics.WriteError != nil { + t.Fatalf("diagnostic result = %#v", result.Diagnostics) + } +} + +func TestReporterClosedHandlesReturnClosedErrorIncludingQuiet(t *testing.T) { + var destination bytes.Buffer + reporter := newReporter(Streams{Out: &destination}) + writer := reporter.Output() + reporter.setVisibility(modeQuiet, false, false) + reporter.close() + + if n, err := writer.Write([]byte("late")); n != 0 || !errors.Is(err, ErrReporterClosed) { + t.Fatalf("late Write() = %d, %v", n, err) + } + if destination.Len() != 0 { + t.Fatalf("late output reached destination: %q", destination.String()) + } +} + +func TestNewReporterNormalizesNilDestinationsAndStartsOrdinary(t *testing.T) { + reporter := newReporter(Streams{}) + if reporter.state.output.writer != io.Discard || reporter.state.diagnostics.writer != io.Discard { + t.Fatalf("nil destinations were not normalized: %#v", reporter.state) + } + if reporter.state.mode != modeOrdinary || reporter.state.progress || reporter.state.progressSet { + t.Fatalf("initial visibility = mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet) + } + reporter.setVisibility(modeDebug, true, true) + if reporter.state.mode != modeDebug || !reporter.state.progress || !reporter.state.progressSet { + t.Fatalf("visibility was not retained: mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet) + } +}