Files
commandkit/reporter.go

381 lines
9.3 KiB
Go

package commandkit
import (
"errors"
"io"
"strings"
"sync"
)
type reportingMode uint8
const (
modeOrdinary reportingMode = iota
modeQuiet
modeVerbose
modeDebug
)
type reportingDestination uint8
const (
destinationOutput reportingDestination = iota
destinationDiagnostics
)
type messageCategory uint8
const (
categoryInfo messageCategory = iota
categoryWarning
categoryDebug
categoryProgress
)
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
}
type coordinatedWriter struct {
state *reportingState
destination reportingDestination
writeError error
}
func (w *coordinatedWriter) Write(p []byte) (int, error) {
n, err := w.state.writeLocked(w.destination, p)
if err != nil && w.writeError == nil {
w.writeError = err
}
return n, err
}
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)
}
// RenderOutput coordinates a complete substantive report rendering operation.
func (r *Reporter) RenderOutput(write func(io.Writer) error) error {
if r == nil || r.state == nil {
return ErrReporterClosed
}
state := r.state
state.mu.Lock()
defer state.mu.Unlock()
if state.closed {
return ErrReporterClosed
}
if write == nil {
return nil
}
destination := state.destination(destinationOutput)
destination.submitted = true
writer := &coordinatedWriter{state: state, destination: destinationOutput}
renderErr := write(writer)
if state.mode == modeQuiet {
destination.suppressed = true
} else if renderErr == nil && writer.writeError == nil {
destination.delivered = true
}
return finishRenderLocked(state, destinationOutput, renderErr, writer.writeError)
}
// Info submits an informational message.
func (r *Reporter) Info(message string, details ...Detail) error {
return r.submitMessage(categoryInfo, message, details)
}
// Warn submits a warning message.
func (r *Reporter) Warn(message string, details ...Detail) error {
return r.submitMessage(categoryWarning, message, details)
}
// Debug submits a debug message.
func (r *Reporter) Debug(message string, details ...Detail) error {
return r.submitMessage(categoryDebug, message, details)
}
// Progress submits a progress message.
func (r *Reporter) Progress(message string, details ...Detail) error {
return r.submitMessage(categoryProgress, message, details)
}
func (r *Reporter) submitMessage(category messageCategory, message string, details []Detail) error {
if r == nil || r.state == nil {
return ErrReporterClosed
}
state := r.state
state.mu.Lock()
defer state.mu.Unlock()
if state.closed {
return ErrReporterClosed
}
details = append([]Detail(nil), details...)
hasDetails := false
for _, detail := range details {
if detail != nil {
hasDetails = true
break
}
}
if message == "" && !hasDetails {
return nil
}
visible := state.visible(category)
destination := state.destination(destinationDiagnostics)
destination.submitted = true
if !visible {
destination.suppressed = true
if !hasDetails {
return nil
}
}
var renderErr, writeErr error
if visible && message != "" {
writer := &coordinatedWriter{state: state, destination: destinationDiagnostics}
_, writeErr = writer.Write([]byte(messageLine(category, message)))
writeErr = writer.writeError
}
if visible && writeErr != nil {
return writeErr
}
for _, detail := range details {
if detail == nil {
continue
}
var detailErr, detailWriteErr error
if visible {
writer := &coordinatedWriter{state: state, destination: destinationDiagnostics}
detailErr = detail(writer)
detailWriteErr = writer.writeError
} else {
detailErr = detail(io.Discard)
}
detailRenderErr := recordRendererErrorLocked(state, destinationDiagnostics, detailErr, detailWriteErr)
if renderErr == nil {
renderErr = detailRenderErr
}
if writeErr == nil {
writeErr = detailWriteErr
}
}
if visible && renderErr == nil && writeErr == nil {
destination.delivered = true
}
return combineOperationErrors(renderErr, writeErr)
}
func (state *reportingState) visible(category messageCategory) bool {
switch category {
case categoryInfo, categoryWarning:
return state.mode == modeVerbose || state.mode == modeDebug
case categoryDebug:
return state.mode == modeDebug
case categoryProgress:
if state.mode == modeQuiet {
return false
}
if state.progressSet {
return state.progress
}
return state.mode == modeDebug
default:
return false
}
}
func messageLine(category messageCategory, message string) string {
prefix := ""
switch category {
case categoryWarning:
prefix = "WARN: "
case categoryDebug:
prefix = "DEBUG: "
}
return prefix + strings.TrimSuffix(message, "\n") + "\n"
}
func finishRenderLocked(state *reportingState, destination reportingDestination, renderErr, writeErr error) error {
renderErr = recordRendererErrorLocked(state, destination, renderErr, writeErr)
return combineOperationErrors(renderErr, writeErr)
}
func recordRendererErrorLocked(state *reportingState, destination reportingDestination, renderErr, writeErr error) error {
if renderErr == nil || (writeErr != nil && errors.Is(renderErr, writeErr)) {
return nil
}
state.recordRenderErrorLocked(destination, renderErr)
return renderErr
}
func combineOperationErrors(renderErr, writeErr error) error {
switch {
case renderErr == nil:
return writeErr
case writeErr == nil || errors.Is(renderErr, writeErr):
return renderErr
default:
return errors.Join(renderErr, writeErr)
}
}
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()
}