diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9fcc7c0..372666e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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). diff --git a/identity.go b/identity.go index 7bd5394..433022c 100644 --- a/identity.go +++ b/identity.go @@ -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 { diff --git a/reporter.go b/reporter.go index ebadab6..9c44107 100644 --- a/reporter.go +++ b/reporter.go @@ -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 } diff --git a/reporter_deferred_test.go b/reporter_deferred_test.go index afb0315..8646465 100644 --- a/reporter_deferred_test.go +++ b/reporter_deferred_test.go @@ -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) diff --git a/reporter_error_test.go b/reporter_error_test.go index b5569eb..489d018 100644 --- a/reporter_error_test.go +++ b/reporter_error_test.go @@ -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) diff --git a/reporter_report_test.go b/reporter_report_test.go index aae645c..d2083ca 100644 --- a/reporter_report_test.go +++ b/reporter_report_test.go @@ -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") diff --git a/reporter_test.go b/reporter_test.go index 8e73aee..efa20e2 100644 --- a/reporter_test.go +++ b/reporter_test.go @@ -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) diff --git a/shared_debug_external_test.go b/shared_debug_external_test.go new file mode 100644 index 0000000..7f6bb04 --- /dev/null +++ b/shared_debug_external_test.go @@ -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) + } +}