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() }