package commandkit import ( "errors" "io" "reflect" "strings" "sync" commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" ) 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 deferred []deferredMessage } type destinationState struct { writer io.Writer submitted bool delivered bool suppressed bool renderError error writeError error } type deferredMessage struct { category messageCategory message string details []Detail } // Reporter coordinates presentation and delivery for one invocation. The // invocation supplies it; consumers do not construct or finalize it. Reporting // operations on one reporter are safe concurrently, although concurrent // submissions have no predetermined order. Rendering callbacks must not // reenter the same reporter, and their supplied writer is valid only while the // callback runs. // // Consumers use Output for substantive command results and the reporting // methods for categorized messages. Delivery and rendering failures are // retained for Outcome.Presentation even when a caller ignores an immediate // method's returned error. type Reporter struct { state *reportingState } // DeferredReporter queues messages for delivery during invocation finalization. // Copies share the same invocation reporting state. Submissions made after // finalization return ErrReporterClosed. type DeferredReporter 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, }} } func (r *Reporter) applySelection(selection commandmeta.ReportingSelection) { if r == nil || r.state == nil { return } state := r.state state.mu.Lock() defer state.mu.Unlock() if state.closed { return } switch selection.Mode { case commandmeta.ReportingModeQuiet: state.mode = modeQuiet case commandmeta.ReportingModeVerbose: state.mode = modeVerbose case commandmeta.ReportingModeDebug: state.mode = modeDebug default: state.mode = modeOrdinary } state.progress = selection.Progress state.progressSet = selection.ProgressSet } // Output returns a managed writer for substantive command results. Writes are // serialized with other operations through this reporter, and failures are // recorded in Outcome.Presentation. The writer returns ErrReporterClosed after // invocation finalization. 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. // 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 // discard destination so rendering failures are retained without writing to // the caller's output destination. 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 to the diagnostic destination. Its // visibility follows the selected reporting mode. func (r *Reporter) Info(message string, details ...Detail) error { return r.submitMessage(categoryInfo, message, details) } // Warn submits a warning message to the diagnostic destination. Its visibility // follows the selected reporting mode. func (r *Reporter) Warn(message string, details ...Detail) error { return r.submitMessage(categoryWarning, message, details) } // Debug submits a debug message to the diagnostic destination. Its visibility // follows the selected reporting mode. func (r *Reporter) Debug(message string, details ...Detail) error { return r.submitMessage(categoryDebug, message, details) } // Progress submits a progress message to the diagnostic destination. Its // visibility follows the selected mode and explicit progress setting. func (r *Reporter) Progress(message string, details ...Detail) error { return r.submitMessage(categoryProgress, message, details) } // AtEnd returns a view for messages delivered after the handler returns and // before terminal explanation delivery. The deferred view shares this // invocation's state. func (r *Reporter) AtEnd() DeferredReporter { if r == nil { return DeferredReporter{} } return DeferredReporter{state: r.state} } // Info queues an informational message for invocation finalization. The // submission error is returned immediately; rendering and delivery errors are // reflected in the final outcome. func (r DeferredReporter) Info(message string, details ...Detail) error { return r.enqueue(categoryInfo, message, details) } // Warn queues a warning message for invocation finalization. The submission // error is returned immediately; rendering and delivery errors are reflected in // the final outcome. func (r DeferredReporter) Warn(message string, details ...Detail) error { return r.enqueue(categoryWarning, message, details) } // Debug queues a debug message for invocation finalization. The submission // error is returned immediately; rendering and delivery errors are reflected in // the final outcome. func (r DeferredReporter) Debug(message string, details ...Detail) error { return r.enqueue(categoryDebug, message, details) } func (r DeferredReporter) enqueue(category messageCategory, message string, details []Detail) error { if r.state == nil { return ErrReporterClosed } state := r.state state.mu.Lock() defer state.mu.Unlock() if state.closed { return ErrReporterClosed } state.deferred = append(state.deferred, deferredMessage{ category: category, message: message, details: append([]Detail(nil), details...), }) return nil } func (r *Reporter) finalize(outcome Outcome, completion func(*Reporter)) Outcome { if r == nil || r.state == nil { return normalizeOutcome(outcome) } state := r.state state.mu.Lock() if state.closed { outcome.Presentation = state.presentationLocked() state.mu.Unlock() return normalizeOutcome(outcome) } deferred := append([]deferredMessage(nil), state.deferred...) state.deferred = nil state.mu.Unlock() for _, message := range deferred { _ = r.submitMessage(message.category, message.message, message.details) } if completion != nil { completion(r) } state.mu.Lock() if presentationFailedLocked(state) { if outcome.Status == 0 && !hasExplanation(outcome.Explanation) { outcome.Explanation.Message = presentationFailureMessage(state) } outcome = applyPresentationFailure(outcome) } if outcome.Status != 0 { outcome = ensureFallbackExplanation(outcome) if state.diagnostics.writeError == nil { _ = renderTerminalLocked(state, outcome.Explanation) } } if presentationFailedLocked(state) { outcome = applyPresentationFailure(outcome) } state.closed = true state.deferred = nil outcome.Presentation = state.presentationLocked() state.mu.Unlock() return normalizeOutcome(outcome) } func normalizeOutcome(outcome Outcome) Outcome { if outcome.Kind == "" && outcome.Status == 0 && outcome.Domain == "" { outcome.Kind = OutcomeSuccess } return outcome } func presentationFailedLocked(state *reportingState) bool { return state.output.renderError != nil || state.output.writeError != nil || state.diagnostics.renderError != nil || state.diagnostics.writeError != nil } func applyPresentationFailure(outcome Outcome) Outcome { switch outcome.Kind { case OutcomeDomain: if outcome.Status == 0 { outcome.Status = 1 } case OutcomeSuccess, OutcomeUsage, OutcomeOperational: outcome.Kind = OutcomeOperational outcome.Status = 1 case "": outcome.Kind = OutcomeOperational outcome.Status = 1 } return outcome } func ensureFallbackExplanation(outcome Outcome) Outcome { if hasExplanation(outcome.Explanation) { return outcome } outcome.Explanation = Explanation{Message: "Command failed."} return outcome } func hasExplanation(explanation Explanation) bool { if explanation.Message != "" { return true } for _, detail := range explanation.Details { if detail != nil { return true } } return false } func presentationFailureMessage(state *reportingState) string { switch { case state.output.renderError != nil: return "Unable to render command output." case state.output.writeError != nil: return "Unable to deliver command output." case state.diagnostics.renderError != nil: return "Unable to render invocation diagnostics." default: return "Unable to deliver invocation diagnostics." } } func renderTerminalLocked(state *reportingState, explanation Explanation) error { destination := state.destination(destinationDiagnostics) destination.submitted = true var renderErr, writeErr error if explanation.Message != "" { writer := &coordinatedWriter{state: state, destination: destinationDiagnostics} _, _ = writer.Write([]byte("ERROR: " + strings.TrimSuffix(explanation.Message, "\n") + "\n")) writeErr = writer.writeError } if writeErr == nil { for _, detail := range explanation.Details { if detail == nil { continue } writer := &coordinatedWriter{state: state, destination: destinationDiagnostics} detailErr := detail(writer) detailWriteErr := writer.writeError detailRenderErr := recordRendererErrorLocked(state, destinationDiagnostics, detailErr, detailWriteErr) if renderErr == nil { renderErr = detailRenderErr } if writeErr == nil { writeErr = detailWriteErr } } } if renderErr == nil && writeErr == nil { destination.delivered = true } return combineOperationErrors(renderErr, writeErr) } 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 && propagatesOnlyWriteError(renderErr, writeErr)) { return nil } state.recordRenderErrorLocked(destination, renderErr) return renderErr } // A joined callback error may contain both a writer failure and independent // rendering evidence. Walk each branch once rather than discarding the entire // error merely because errors.Is finds the writer failure somewhere in it. func propagatesOnlyWriteError(err, writeErr error) bool { for err != nil { if reflect.TypeOf(err).Comparable() && err == writeErr { return true } switch wrapped := err.(type) { case interface{ Unwrap() []error }: found := false for _, child := range wrapped.Unwrap() { if child != nil { found = true if !propagatesOnlyWriteError(child, writeErr) { return false } } } if found { return true } case interface{ Unwrap() error }: if child := wrapped.Unwrap(); child != nil { err = child continue } } return errors.Is(err, writeErr) } return false } 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() }