Add managed reporter output state
This commit is contained in:
@@ -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.
|
||||
|
||||
10
execute.go
10
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)
|
||||
}
|
||||
|
||||
10
identity.go
10
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
|
||||
|
||||
186
reporter.go
Normal file
186
reporter.go
Normal file
@@ -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()
|
||||
}
|
||||
174
reporter_test.go
Normal file
174
reporter_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user