From b6f22462d3201eb4a429e1bc7080e6ecadc10d82 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 26 Oct 2026 03:38:48 +0000 Subject: [PATCH] Add a core reporting example --- docs/roadmap/implementation.md | 12 ++- examples/core/main.go | 91 +++++++++++++++++++++- examples/core/main_test.go | 136 +++++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 5 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6e960b6..cc0fe9e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Invocation reporting implementation plan -Status: Stages 1–15 complete. Stage 16 is next. +Status: Stages 1–16 complete. Stage 17 is next. ## Objective and authority @@ -610,7 +610,7 @@ module-wide `go test ./...` reaches and passes that package but cannot compile ## Stage 16: Maintain a core reporting consumer -Status: pending. +Status: complete. Primary files: [examples/core/main.go](../../examples/core/main.go) and its existing example tests. @@ -632,6 +632,14 @@ Validation: keep the example runnable offline and cover representative modes and failure paths with its owning suite. Use package tests for detailed cause identity and delivery states rather than expanding the example into a framework. +Completion: migrated the core example command to the invocation reporter and +added a deterministic reporting command with result, warning, info, progress, +debug, recovery, failure, and partial-output scenarios. Its tests cover ordinary, +progress, verbose, debug, and quiet output, plus cause identity and terminal +explanations. `GOTOOLCHAIN=local go test -count=1 ./examples/core` and the debug +demo pass; the partial-output demo prints its completed section and terminal +explanation, then exits with status 1 as expected. + ## Stage 17: Migrate maintained integration consumers Status: pending. diff --git a/examples/core/main.go b/examples/core/main.go index ce4aa70..ce56974 100644 --- a/examples/core/main.go +++ b/examples/core/main.go @@ -2,7 +2,9 @@ package main import ( "context" + "errors" "fmt" + "io" "os" "strconv" @@ -14,6 +16,16 @@ type greetInput struct { Tags commandkit.Supplied[[]string] } +type reportInput struct { + Scenario string +} + +var ( + errRecoveredFixture = errors.New("a local read failed and was recovered") + errWarningFixture = errors.New("a local warning has a cause") + errPartialFixture = errors.New("the local report could not be completed") +) + func newApp() (*commandkit.App, error) { countDefault := 1 tagDefaults := []string{"demo"} @@ -29,6 +41,7 @@ func newApp() (*commandkit.App, error) { }), }, Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome { + reporter := invocation.Reporter() count := fmt.Sprintf("default %d (not supplied)", input.Count.Value) if input.Count.Set { count = fmt.Sprintf("explicit %d", input.Count.Value) @@ -37,15 +50,39 @@ func newApp() (*commandkit.App, error) { if input.Tags.Set { tags = fmt.Sprintf("explicit %v", input.Tags.Value) } - if _, err := fmt.Fprintf(invocation.Streams.Out, "Hello; count=%s; tags=%s; entered=%q; canonical=%q\n", count, tags, invocation.EnteredPath(), invocation.CanonicalPath()); err != nil { - return commandkit.Failure(err) - } + _ = reporter.RenderOutput(func(writer io.Writer) error { + _, err := fmt.Fprintf(writer, "Hello; count=%s; tags=%s; entered=%q; canonical=%q\n", count, tags, invocation.EnteredPath(), invocation.CanonicalPath()) + return err + }) return commandkit.Success() }, }) + report := commandkit.NewCommand(commandkit.CommandSpec[reportInput]{ + Name: "report", + Summary: "Demonstrate invocation reporting with local scenarios.", + NewInput: func() reportInput { + return reportInput{Scenario: "demo"} + }, + Arguments: []commandkit.Argument[reportInput]{ + commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "scenario", Usage: "demo, recover, warn, warn-error, fail, or partial."}, false, func(value string) (string, error) { return value, nil }, + func(input *reportInput, value commandkit.Supplied[string]) { input.Scenario = value.Value }), + }, + CheckInput: func(input reportInput) error { + switch input.Scenario { + case "demo", "recover", "warn", "warn-error", "fail", "partial": + return nil + default: + return fmt.Errorf("unknown reporting scenario %q", input.Scenario) + } + }, + Run: func(invocation commandkit.Invocation, input reportInput) commandkit.Outcome { + return runReportingExample(invocation, input.Scenario) + }, + }) workspace := commandkit.Group("workspace", "Workspace commands.", greet) declarations := []commandkit.Declaration{ workspace, + report, commandkit.Alias("hello", "workspace greet"), commandkit.Alias("ws", "workspace"), commandkit.FlagAliasFor("workspace greet", "--times", "--count"), @@ -54,6 +91,54 @@ func newApp() (*commandkit.App, error) { return commandkit.New(commandkit.Identity{Name: "core-example", Version: "1.0"}, declarations...) } +func runReportingExample(invocation commandkit.Invocation, scenario string) commandkit.Outcome { + reporter := invocation.Reporter() + deferred := reporter.AtEnd() + switch scenario { + case "demo": + _ = reporter.Progress("Loaded one local fixture.") + _ = deferred.Warn("Optional fixture warning", func(writer io.Writer) error { + _, err := io.WriteString(writer, "The sample contains an optional note.\n") + return err + }) + _ = deferred.Info("Using deterministic local data.") + _ = deferred.Debug("Selected the built-in reporting example.") + _ = deferred.Info("Summary", writeExampleSummary) + _ = reporter.RenderOutput(writeExampleReport) + return commandkit.Success() + case "recover": + return commandkit.Success().WithCause(errRecoveredFixture) + case "warn": + _ = deferred.Warn("The local operation completed with a warning.") + return commandkit.Success() + case "warn-error": + _ = deferred.Warn("The local operation completed with a warning.") + return commandkit.Success().WithCause(errWarningFixture) + case "fail": + return commandkit.Failure("The selected local operation could not be completed.") + case "partial": + _ = reporter.RenderOutput(writePartialExampleReport) + return commandkit.Failure("The local report stopped after partial output.").WithCause(errPartialFixture) + default: + return commandkit.Usage(fmt.Sprintf("unknown reporting scenario %q", scenario)) + } +} + +func writeExampleReport(writer io.Writer) error { + _, err := fmt.Fprintln(writer, "Prepared a local report from one fixture.") + return err +} + +func writePartialExampleReport(writer io.Writer) error { + _, err := io.WriteString(writer, "Partial local report: first section complete.\n") + return err +} + +func writeExampleSummary(writer io.Writer) error { + _, err := io.WriteString(writer, "Processed one local fixture.\n") + return err +} + func main() { app, err := newApp() if err != nil { diff --git a/examples/core/main_test.go b/examples/core/main_test.go index b68a001..1c183e5 100644 --- a/examples/core/main_test.go +++ b/examples/core/main_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "errors" "io" "strings" "testing" @@ -61,3 +62,138 @@ func TestGlobalHelpAliasDoesNotRunCoreExampleCommand(t *testing.T) { t.Fatalf("group shortcut help outcome/output = (%#v, %q)", outcome, output.String()) } } + +func TestReportingExampleModes(t *testing.T) { + for _, test := range []struct { + name string + args []string + wantOutput []string + wantDiag []string + omitDiag []string + }{ + { + name: "ordinary result", + args: []string{"report", "demo"}, + wantOutput: []string{"Prepared a local report from one fixture."}, + omitDiag: []string{"Optional fixture warning", "Using deterministic local data.", "Loaded one local fixture.", "DEBUG:"}, + }, + { + name: "progress selected", + args: []string{"report", "demo", "--progress"}, + wantOutput: []string{"Prepared a local report from one fixture."}, + wantDiag: []string{"Loaded one local fixture."}, + omitDiag: []string{"WARN:", "Using deterministic local data.", "DEBUG:"}, + }, + { + name: "verbose messages", + args: []string{"report", "demo", "--verbose"}, + wantOutput: []string{"Prepared a local report from one fixture."}, + wantDiag: []string{"WARN: Optional fixture warning", "The sample contains an optional note.", "Using deterministic local data.", "Summary", "Processed one local fixture."}, + omitDiag: []string{"Loaded one local fixture.", "DEBUG:"}, + }, + { + name: "debug messages", + args: []string{"report", "demo", "--debug"}, + wantOutput: []string{"Prepared a local report from one fixture."}, + wantDiag: []string{"WARN: Optional fixture warning", "Using deterministic local data.", "Loaded one local fixture.", "DEBUG: Selected the built-in reporting example.", "Processed one local fixture."}, + }, + { + name: "quiet suppresses result and messages", + args: []string{"report", "demo", "--quiet"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + outcome, output, diagnostics := executeExample(t, test.args...) + if outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("Execute(%q) = %#v", test.args, outcome) + } + for _, fragment := range test.wantOutput { + if !strings.Contains(output, fragment) { + t.Errorf("output %q does not contain %q", output, fragment) + } + } + for _, fragment := range test.wantDiag { + if !strings.Contains(diagnostics, fragment) { + t.Errorf("diagnostics %q does not contain %q", diagnostics, fragment) + } + } + for _, fragment := range test.omitDiag { + if strings.Contains(diagnostics, fragment) { + t.Errorf("diagnostics %q unexpectedly contain %q", diagnostics, fragment) + } + } + if test.name == "quiet suppresses result and messages" && (output != "" || diagnostics != "") { + t.Errorf("quiet output/diagnostics = (%q, %q), want both empty", output, diagnostics) + } + }) + } +} + +func TestReportingExampleScenarios(t *testing.T) { + t.Run("silent recovery retains cause", func(t *testing.T) { + outcome, output, diagnostics := executeExample(t, "report", "recover") + if outcome.Kind != commandkit.OutcomeSuccess || !errors.Is(outcome.Cause, errRecoveredFixture) { + t.Fatalf("outcome = %#v, want successful recovery with original cause", outcome) + } + if output != "" || diagnostics != "" { + t.Fatalf("recovery output/diagnostics = (%q, %q), want silent recovery", output, diagnostics) + } + }) + + t.Run("warning without cause", func(t *testing.T) { + outcome, _, diagnostics := executeExample(t, "report", "warn", "--verbose") + if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil { + t.Fatalf("outcome = %#v, want success without cause", outcome) + } + if !strings.Contains(diagnostics, "WARN: The local operation completed with a warning.") { + t.Fatalf("diagnostics = %q, want explicit warning", diagnostics) + } + }) + + t.Run("warning with independent cause", func(t *testing.T) { + outcome, _, diagnostics := executeExample(t, "report", "warn-error", "--verbose") + if outcome.Kind != commandkit.OutcomeSuccess || !errors.Is(outcome.Cause, errWarningFixture) { + t.Fatalf("outcome = %#v, want successful warning with original cause", outcome) + } + if !strings.Contains(diagnostics, "WARN: The local operation completed with a warning.") || strings.Contains(diagnostics, errWarningFixture.Error()) { + t.Fatalf("diagnostics = %q, want warning text without cause text", diagnostics) + } + }) + + t.Run("terminal explanation without cause", func(t *testing.T) { + outcome, _, diagnostics := executeExample(t, "report", "fail", "--quiet") + if outcome.Kind != commandkit.OutcomeOperational || outcome.Status == 0 || outcome.Cause != nil { + t.Fatalf("outcome = %#v, want terminal failure without cause", outcome) + } + if outcome.Explanation.Message != "The selected local operation could not be completed." || !strings.Contains(diagnostics, outcome.Explanation.Message) { + t.Fatalf("explanation/diagnostics = (%q, %q)", outcome.Explanation.Message, diagnostics) + } + }) + + t.Run("partial output keeps cause independent", func(t *testing.T) { + outcome, output, diagnostics := executeExample(t, "report", "partial") + if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, errPartialFixture) { + t.Fatalf("outcome = %#v, want terminal failure with original cause", outcome) + } + if !strings.Contains(output, "Partial local report: first section complete.") { + t.Fatalf("output = %q, want completed first section", output) + } + if outcome.Explanation.Message != "The local report stopped after partial output." || !strings.Contains(diagnostics, outcome.Explanation.Message) { + t.Fatalf("explanation/diagnostics = (%q, %q)", outcome.Explanation.Message, diagnostics) + } + if strings.Contains(diagnostics, errPartialFixture.Error()) { + t.Errorf("diagnostics %q unexpectedly contain cause text", diagnostics) + } + }) +} + +func executeExample(t *testing.T, args ...string) (commandkit.Outcome, string, string) { + t.Helper() + app, err := newApp() + if err != nil { + t.Fatal(err) + } + var output, diagnostics bytes.Buffer + outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostics}) + return outcome, output.String(), diagnostics.String() +}