Expose invocation reporting mode

This commit is contained in:
2026-11-08 14:59:37 +00:00
parent 5e5f61a306
commit 7dd30f7df3
8 changed files with 147 additions and 51 deletions

View File

@@ -1,6 +1,6 @@
# Shared debug binding implementation plan
Status: planned; no implementation stages complete.
Status: in progress; Stage 1 complete.
Baseline: `6727d22ce6e1ac668b56a3e6d8fa5f7a06e54d88`.
Execution audience: Luna coding agent.
@@ -70,6 +70,11 @@ publish releases, push refs, or implement Audita artifact capture.
## Stage 1 — Expose the resolved reporting mode
**Complete.** The invocation exposes the existing reporter selection, including
zero and finalized values. Focused reporting and public accessor checks passed
with Go 1.27.0 and offline module resolution; concurrent reads also passed with
`-race -count=1`.
Primary files: [reporter.go](../../reporter.go),
[identity.go](../../identity.go), [execute.go](../../execute.go).

View File

@@ -59,6 +59,21 @@ type Invocation struct {
// manually constructed Invocation has no reporter.
func (inv Invocation) Reporter() *Reporter { return inv.reporter }
// ReportingMode returns the selected reporting mode, independently of delivery
// success. The selection is stable during the handler and remains available
// after finalization. A zero Invocation returns ReportingModeOrdinary. Reads
// are safe alongside concurrent reporting; reporting callbacks must not reenter
// the same reporter.
func (inv Invocation) ReportingMode() ReportingMode {
if inv.reporter == nil || inv.reporter.state == nil {
return ReportingModeOrdinary
}
state := inv.reporter.state
state.mu.Lock()
defer state.mu.Unlock()
return state.mode
}
// Help renders the selected command's help through this invocation's output.
// It returns an operational failure when the invocation has no help renderer.
func (inv Invocation) Help() Outcome {

View File

@@ -10,13 +10,19 @@ import (
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
)
type reportingMode uint8
// ReportingMode identifies the invocation's selected reporting visibility.
// Its zero value is ReportingModeOrdinary.
type ReportingMode uint8
const (
modeOrdinary reportingMode = iota
modeQuiet
modeVerbose
modeDebug
// ReportingModeOrdinary uses ordinary reporting visibility.
ReportingModeOrdinary ReportingMode = iota
// ReportingModeQuiet suppresses ordinary output and messages.
ReportingModeQuiet
// ReportingModeVerbose enables verbose reporting.
ReportingModeVerbose
// ReportingModeDebug enables debug reporting.
ReportingModeDebug
)
type reportingDestination uint8
@@ -41,7 +47,7 @@ type reportingState struct {
diagnostics destinationState
closed bool
deferredClosed bool
mode reportingMode
mode ReportingMode
format Format
progress bool
progressSet bool
@@ -109,7 +115,7 @@ func newReporter(streams Streams) *Reporter {
return &Reporter{state: &reportingState{
output: destinationState{writer: streams.Out},
diagnostics: destinationState{writer: streams.Err},
mode: modeOrdinary,
mode: ReportingModeOrdinary,
}}
}
@@ -125,13 +131,13 @@ func (r *Reporter) applySelection(selection commandmeta.ReportingSelection) {
}
switch selection.Mode {
case commandmeta.ReportingModeQuiet:
state.mode = modeQuiet
state.mode = ReportingModeQuiet
case commandmeta.ReportingModeVerbose:
state.mode = modeVerbose
state.mode = ReportingModeVerbose
case commandmeta.ReportingModeDebug:
state.mode = modeDebug
state.mode = ReportingModeDebug
default:
state.mode = modeOrdinary
state.mode = ReportingModeOrdinary
}
state.progress = selection.Progress
state.progressSet = selection.ProgressSet
@@ -189,7 +195,7 @@ func (r *Reporter) RenderOutput(write func(io.Writer) error) error {
destination.submitted = true
writer := &coordinatedWriter{state: state, destination: destinationOutput}
renderErr := write(writer)
if state.mode == modeQuiet {
if state.mode == ReportingModeQuiet {
destination.suppressed = true
} else if renderErr == nil && writer.writeError == nil {
destination.delivered = true
@@ -489,17 +495,17 @@ func renderDetailsLocked(state *reportingState, details []Detail, visible bool)
func (state *reportingState) visible(category messageCategory) bool {
switch category {
case categoryInfo, categoryWarning:
return state.mode == modeVerbose || state.mode == modeDebug
return state.mode == ReportingModeVerbose || state.mode == ReportingModeDebug
case categoryDebug:
return state.mode == modeDebug
return state.mode == ReportingModeDebug
case categoryProgress:
if state.mode == modeQuiet {
if state.mode == ReportingModeQuiet {
return false
}
if state.progressSet {
return state.progress
}
return state.mode == modeDebug
return state.mode == ReportingModeDebug
default:
return false
}
@@ -617,7 +623,7 @@ func (state *reportingState) destination(destination reportingDestination) *dest
func (state *reportingState) suppressed(destination reportingDestination) bool {
if destination == destinationOutput {
return state.mode == modeQuiet
return state.mode == ReportingModeQuiet
}
return false
}

View File

@@ -10,7 +10,7 @@ import (
)
func TestHiddenSimpleDeferredMessagesDoNotAccumulate(t *testing.T) {
for _, mode := range []reportingMode{modeOrdinary, modeQuiet} {
for _, mode := range []ReportingMode{ReportingModeOrdinary, ReportingModeQuiet} {
t.Run(fmt.Sprint(mode), func(t *testing.T) {
reporter := newReporter(Streams{})
setReporterVisibility(reporter, mode, false, false)
@@ -45,7 +45,7 @@ func TestHiddenSimpleDeferredMessagesDoNotAccumulate(t *testing.T) {
func TestHiddenDeferredDetailsRemainDeferredAndCaptured(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
setReporterVisibility(reporter, modeQuiet, false, false)
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
failure := errors.New("hidden rendering failed")
var order []string
details := []Detail{func(w io.Writer) error {
@@ -87,7 +87,7 @@ func TestReporterDeferredAcceptanceEndsBeforeDelivery(t *testing.T) {
writer := &gatedDiagnosticWriter{entered: make(chan struct{}), release: make(chan struct{})}
entered := writer.entered
reporter := newReporter(Streams{Err: writer})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
deferred := reporter.AtEnd()
if err := deferred.Warn("accepted"); err != nil {
t.Fatal(err)

View File

@@ -16,7 +16,7 @@ func TestReporterExplainsItsOwnFailuresWithoutExposingCauses(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
if quiet {
setReporterVisibility(reporter, modeQuiet, false, false)
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
}
failure := errors.New("private rendering detail")
render := func(io.Writer) error { return failure }
@@ -84,7 +84,7 @@ func TestReporterPreservesJoinedRendererEvidence(t *testing.T) {
writeErr := errors.Join(errors.New("sink failure"), errors.New("sink detail"))
destination := &reporterTestWriter{err: writeErr}
reporter := newReporter(Streams{Out: destination, Err: destination})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
render := func(w io.Writer) error {
_, err := io.WriteString(w, "report")
return test.combine(renderErr, err)

View File

@@ -12,7 +12,7 @@ import (
func TestReporterReportQuietAndClosed(t *testing.T) {
var out bytes.Buffer
reporter := newReporter(Streams{Out: &out})
reporter.state.mode = modeQuiet
reporter.state.mode = ReportingModeQuiet
err := reporter.Report(Report{Value: make(chan int)})
if err == nil {
t.Fatal("expected serialization error")

View File

@@ -80,7 +80,7 @@ func TestReporterLeavesCallerWriterOpen(t *testing.T) {
func TestReporterQuietOutputDiscardsSuccessfully(t *testing.T) {
var destination bytes.Buffer
reporter := newReporter(Streams{Out: &destination})
setReporterVisibility(reporter, modeQuiet, false, false)
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
writer := reporter.Output()
if n, err := writer.Write([]byte("hidden")); err != nil || n != len("hidden") {
@@ -177,7 +177,7 @@ func TestReporterClosedHandlesReturnClosedErrorIncludingQuiet(t *testing.T) {
var destination bytes.Buffer
reporter := newReporter(Streams{Out: &destination})
writer := reporter.Output()
setReporterVisibility(reporter, modeQuiet, false, false)
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
_ = reporter.finalize(Success(), nil)
if n, err := writer.Write([]byte("late")); n != 0 || !errors.Is(err, ErrReporterClosed) {
@@ -193,11 +193,11 @@ func TestNewReporterNormalizesNilDestinationsAndStartsOrdinary(t *testing.T) {
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 {
if reporter.state.mode != ReportingModeOrdinary || 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)
}
setReporterVisibility(reporter, modeDebug, true, true)
if reporter.state.mode != modeDebug || !reporter.state.progress || !reporter.state.progressSet {
setReporterVisibility(reporter, ReportingModeDebug, true, true)
if reporter.state.mode != ReportingModeDebug || !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)
}
}
@@ -205,17 +205,17 @@ func TestNewReporterNormalizesNilDestinationsAndStartsOrdinary(t *testing.T) {
func TestReporterMessageVisibilityAndProgressPrecedence(t *testing.T) {
tests := []struct {
name string
mode reportingMode
mode ReportingMode
progress bool
progressSet bool
want string
}{
{name: "ordinary", mode: modeOrdinary},
{name: "verbose", mode: modeVerbose, want: "not used\nWARN: not used\n"},
{name: "debug", mode: modeDebug, want: "not used\nWARN: not used\nDEBUG: DEBUG\nPROGRESS\n"},
{name: "quiet overrides progress", mode: modeQuiet, progress: true, progressSet: true},
{name: "explicit ordinary progress", mode: modeOrdinary, progress: true, progressSet: true, want: "PROGRESS\n"},
{name: "explicit debug disable", mode: modeDebug, progressSet: true, want: "not used\nWARN: not used\nDEBUG: DEBUG\n"},
{name: "ordinary", mode: ReportingModeOrdinary},
{name: "verbose", mode: ReportingModeVerbose, want: "not used\nWARN: not used\n"},
{name: "debug", mode: ReportingModeDebug, want: "not used\nWARN: not used\nDEBUG: DEBUG\nPROGRESS\n"},
{name: "quiet overrides progress", mode: ReportingModeQuiet, progress: true, progressSet: true},
{name: "explicit ordinary progress", mode: ReportingModeOrdinary, progress: true, progressSet: true, want: "PROGRESS\n"},
{name: "explicit debug disable", mode: ReportingModeDebug, progressSet: true, want: "not used\nWARN: not used\nDEBUG: DEBUG\n"},
}
for _, test := range tests {
@@ -237,7 +237,7 @@ func TestReporterMessageVisibilityAndProgressPrecedence(t *testing.T) {
func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
if err := reporter.Warn("Native warning\nwith second line\n", nil,
func(w io.Writer) error { _, err := io.WriteString(w, "detail one\n"); return err },
func(w io.Writer) error { _, err := io.WriteString(w, "detail two\n"); return err }); err != nil {
@@ -258,7 +258,7 @@ func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) {
func TestReporterHiddenRenderersRunAgainstDiscardAndRetainErrors(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
setReporterVisibility(reporter, modeQuiet, false, false)
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
renderErr := errors.New("native renderer failed")
calls := 0
err := reporter.Warn("hidden warning", func(w io.Writer) error {
@@ -284,7 +284,7 @@ func TestReporterRecordsIgnoredRenderErrors(t *testing.T) {
renderErr := errors.New("output renderer failed")
diagnosticErr := errors.New("diagnostic renderer failed")
reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
_ = reporter.RenderOutput(func(io.Writer) error { return renderErr })
_ = reporter.Warn("warning", func(io.Writer) error { return diagnosticErr })
@@ -297,7 +297,7 @@ func TestReporterRecordsIgnoredRenderErrors(t *testing.T) {
func TestReporterRenderOutputRunsInQuietAndRetainsFailure(t *testing.T) {
destination := &reporterTestWriter{}
reporter := newReporter(Streams{Out: destination})
setReporterVisibility(reporter, modeQuiet, false, false)
setReporterVisibility(reporter, ReportingModeQuiet, false, false)
renderErr := errors.New("quiet encoder failed")
calls := 0
_ = reporter.RenderOutput(func(w io.Writer) error {
@@ -321,7 +321,7 @@ func TestReporterCountsSuccessfulZeroByteRenderAsSubmitted(t *testing.T) {
if err := reporter.RenderOutput(func(io.Writer) error { return nil }); err != nil {
t.Fatal(err)
}
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
if err := reporter.Warn("", func(io.Writer) error { return nil }); err != nil {
t.Fatal(err)
}
@@ -409,7 +409,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
t.Run("render callback and message", func(t *testing.T) {
var output bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &output})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
start := make(chan struct{})
ready := make(chan struct{}, 2)
done := make(chan struct{}, 2)
@@ -443,7 +443,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
t.Run("message details and render callback", func(t *testing.T) {
var output bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &output})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
start := make(chan struct{})
ready := make(chan struct{}, 2)
done := make(chan struct{}, 2)
@@ -481,7 +481,7 @@ func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
if err := reporter.Warn("", func(w io.Writer) error {
_, err := io.WriteString(w, "native multiline\nsecond line\n")
return err
@@ -499,7 +499,7 @@ func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) {
func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *testing.T) {
var output, diagnostics bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &diagnostics})
setReporterVisibility(reporter, modeDebug, false, false)
setReporterVisibility(reporter, ReportingModeDebug, false, false)
if err := reporter.Info("started"); err != nil {
t.Fatal(err)
}
@@ -525,7 +525,7 @@ func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *test
func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
setReporterVisibility(reporter, modeVerbose, false, false)
setReporterVisibility(reporter, ReportingModeVerbose, false, false)
writeFirst := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "first detail\n"); return err })
writeSecond := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "second detail\n"); return err })
details := []Detail{writeFirst}
@@ -545,7 +545,7 @@ func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *te
renderErr := errors.New("deferred renderer failed")
failedReporter := newReporter(Streams{Err: io.Discard})
setReporterVisibility(failedReporter, modeQuiet, false, false)
setReporterVisibility(failedReporter, ReportingModeQuiet, false, false)
if err := failedReporter.AtEnd().Warn("hidden", func(io.Writer) error { return renderErr }); err != nil {
t.Fatal(err)
}
@@ -699,14 +699,14 @@ func TestReporterFinalizationClosesOutputAndDeferredHandles(t *testing.T) {
}
}
func setReporterVisibility(reporter *Reporter, mode reportingMode, progress, progressSet bool) {
func setReporterVisibility(reporter *Reporter, mode ReportingMode, progress, progressSet bool) {
selection := commandmeta.ReportingSelection{Progress: progress, ProgressSet: progressSet}
switch mode {
case modeQuiet:
case ReportingModeQuiet:
selection.Mode = commandmeta.ReportingModeQuiet
case modeVerbose:
case ReportingModeVerbose:
selection.Mode = commandmeta.ReportingModeVerbose
case modeDebug:
case ReportingModeDebug:
selection.Mode = commandmeta.ReportingModeDebug
}
reporter.applySelection(selection)

View File

@@ -0,0 +1,70 @@
package commandkit_test
import (
"context"
"io"
"sync"
"testing"
commandkit "go.maximumdirect.net/commandkit"
)
func TestInvocationReportingMode(t *testing.T) {
if got := (commandkit.Invocation{}).ReportingMode(); got != commandkit.ReportingModeOrdinary {
t.Fatalf("zero invocation mode = %v", got)
}
var retained commandkit.Invocation
var observed commandkit.ReportingMode
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(inv commandkit.Invocation, _ struct{}) commandkit.Outcome {
retained = inv
observed = inv.ReportingMode()
return commandkit.Success()
}})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
flag string
want commandkit.ReportingMode
}{{"", commandkit.ReportingModeOrdinary}, {"--quiet", commandkit.ReportingModeQuiet}, {"--verbose", commandkit.ReportingModeVerbose}, {"--debug", commandkit.ReportingModeDebug}, {"", commandkit.ReportingModeOrdinary}} {
args := []string{"run"}
if tc.flag != "" {
args = append(args, tc.flag)
}
result := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || observed != tc.want || retained.ReportingMode() != tc.want {
t.Fatalf("%q: outcome=%#v mode=%v", tc.flag, result, retained.ReportingMode())
}
}
}
func TestInvocationReportingModeConcurrentReads(t *testing.T) {
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(inv commandkit.Invocation, _ struct{}) commandkit.Outcome {
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for j := 0; j < 100; j++ {
if inv.ReportingMode() != commandkit.ReportingModeDebug {
t.Error("mode changed")
}
_ = inv.Reporter().Debug("diagnostic")
}
}()
}
close(start)
wg.Wait()
return commandkit.Success()
}})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
if outcome := app.Execute(context.Background(), []string{"run", "--debug"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatal(outcome)
}
}