603 lines
16 KiB
Go
603 lines
16 KiB
Go
package commandkit
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"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) {
|
|
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 outcome.Explanation.Message != "" {
|
|
return outcome
|
|
}
|
|
for _, detail := range outcome.Explanation.Details {
|
|
if detail != nil {
|
|
return outcome
|
|
}
|
|
}
|
|
outcome.Explanation = Explanation{Message: "Command failed."}
|
|
return outcome
|
|
}
|
|
|
|
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 && 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()
|
|
}
|