diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 3823bb2..5f3df16 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -70,7 +70,7 @@ are those selected in the roadmap, without runtime detection machinery. ## Stage 1: Replace outcome and provider data declarations -Status: pending. +Status: complete. Primary files: [outcome.go](../../outcome.go) and their focused contract tests. diff --git a/outcome.go b/outcome.go index 62f76f4..d55f2ff 100644 --- a/outcome.go +++ b/outcome.go @@ -1,18 +1,23 @@ package commandkit import ( + "errors" "io" "strings" ) -// Severity classifies a neutral diagnostic. +// Severity classifies a nonterminal diagnostic. type Severity string const ( - // SeverityWarning indicates information that does not fail the operation. + // SeverityInfo marks informational content. + SeverityInfo Severity = "info" + // SeverityWarning marks a concern that does not fail the operation. SeverityWarning Severity = "warning" - // SeverityError indicates a diagnostic associated with an error. - SeverityError Severity = "error" + // SeverityDebug marks diagnostic detail for debug output. + SeverityDebug Severity = "debug" + // SeverityProgress marks progress information. + SeverityProgress Severity = "progress" ) // OutcomeKind classifies the result of a command invocation. @@ -29,88 +34,134 @@ const ( OutcomeDomain OutcomeKind = "domain" ) -// DeliveryState describes whether CommandKit completed diagnostic delivery. -type DeliveryState string +// DeliveryState describes the result of presenting content to a destination. +type DeliveryState uint8 const ( - // DeliveryNone indicates that no diagnostic delivery was attempted. - DeliveryNone DeliveryState = "none" - // DeliveryComplete indicates that diagnostic delivery completed. - DeliveryComplete DeliveryState = "complete" - // DeliveryIncomplete indicates that diagnostic delivery did not complete. - DeliveryIncomplete DeliveryState = "incomplete" + // DeliveryNone indicates that no content was submitted to the destination. + DeliveryNone DeliveryState = iota + // DeliverySuppressed indicates that all submitted content was intentionally hidden. + DeliverySuppressed + // DeliveryComplete indicates that all requested delivery completed. + DeliveryComplete + // DeliveryIncomplete indicates that rendering or destination writing failed. + DeliveryIncomplete ) -// Diagnostic is a deferred message written to an invocation's error stream. +// ErrReporterClosed is returned when a reporting handle is used after invocation finalization. +var ErrReporterClosed = errors.New("reporter is closed") + +// Detail renders optional content to the supplied writer. +type Detail func(io.Writer) error + +// Explanation contains terminal content for a failed outcome. +type Explanation struct { + // Message is the literal opening text. + Message string + // Details render additional content in order. + Details []Detail +} + +// PresentationResult records delivery observations for the two output channels. +type PresentationResult struct { + // Output records delivery to the result destination. + Output DeliveryResult + // Diagnostics records delivery to the diagnostic destination. + Diagnostics DeliveryResult +} + +// DeliveryResult records rendering and writing errors for one destination. +type DeliveryResult struct { + // State summarizes content delivery to the destination. + State DeliveryState + // RenderError is the first rendering error for the destination. + RenderError error + // WriteError is the first destination write error. + WriteError error +} + +// Diagnostic is passive nonterminal message data returned by a provider. // -// Write is called by CommandKit's diagnostic delivery path. It should return -// any error encountered while writing. A nil Write function has no output. +// Write renders the diagnostic to a supplied writer when the owning integration +// submits it through the invocation reporter. A nil Write function has no detail. type Diagnostic struct { // Severity classifies the diagnostic without depending on a provider library. Severity Severity - // Write writes the diagnostic to the supplied writer. - Write func(io.Writer) error + // Write renders the diagnostic to the supplied writer. + Write Detail } -// Acquired pairs a provider value with diagnostics emitted during acquisition. +// Acquired pairs a provider value with independent diagnostics and failure context. type Acquired[T any] struct { // Value is the value returned by the acquisition callback. Value T - // Diagnostics are retained for delivery by the invocation coordinator. + // Diagnostics are passive nonterminal messages for the owning command to submit. Diagnostics []Diagnostic + // Explanation provides terminal context if the owning command returns failure. + Explanation Explanation } // Outcome is the classified result of an operation. // -// Cause records the operation error. DeliveryError records an error encountered -// while presenting diagnostics or reports; it is kept separate from Cause so -// both failures remain inspectable. Outcome does not implement error. -// Invocation coordination treats a zero Outcome as success with status 0. +// Cause records an optional operation error without selecting the outcome or +// generating operator-facing text. Presentation records delivery observations +// separately. Invocation coordination treats a zero Outcome as success with status 0. type Outcome struct { // Kind classifies the result. Kind OutcomeKind // Status is the suggested process status for this result. Status int - // Domain names the application-defined classification when Kind is - // OutcomeDomain. + // Domain names the application-defined classification when Kind is OutcomeDomain. Domain string - // Cause is the error returned by the operation. + // Cause is the optional operation error, preserved for caller inspection. Cause error - // DeliveryError is an error encountered while writing a report or - // diagnostics. - DeliveryError error - // DiagnosticDelivery records whether diagnostic writing completed. Its zero - // value is treated as DeliveryNone by invocation coordination. - DiagnosticDelivery DeliveryState - // ReportIncomplete is true when a failed write left a report incomplete. - ReportIncomplete bool - // Diagnostics are delivered in slice order by invocation coordination. - Diagnostics []Diagnostic + // Explanation contains terminal content for a failed outcome. + Explanation Explanation + // Presentation records delivery observations populated by CommandKit. + Presentation PresentationResult } // Success returns a successful outcome with status 0. func Success() Outcome { - return Outcome{Kind: OutcomeSuccess, Status: 0, DiagnosticDelivery: DeliveryNone} + return Outcome{Kind: OutcomeSuccess} } -// Usage returns a usage outcome with status 2 and the supplied cause. -func Usage(cause error) Outcome { - return Outcome{Kind: OutcomeUsage, Status: 2, Cause: cause, DiagnosticDelivery: DeliveryNone} +// Failure returns an operational outcome with status 1 and a terminal explanation. +func Failure(message string, details ...Detail) Outcome { + return Outcome{Kind: OutcomeOperational, Status: 1, Explanation: Explanation{Message: message, Details: cloneDetails(details)}} } -// Failure returns an operational outcome with status 1 and the supplied cause. -func Failure(cause error) Outcome { - return Outcome{Kind: OutcomeOperational, Status: 1, Cause: cause, DiagnosticDelivery: DeliveryNone} +// Usage returns a usage outcome with status 2 and a terminal explanation. +func Usage(message string, details ...Detail) Outcome { + return Outcome{Kind: OutcomeUsage, Status: 2, Explanation: Explanation{Message: message, Details: cloneDetails(details)}} } -// Domain returns an application-domain outcome preserving the supplied name, -// status, and cause. -func Domain(name string, status int, cause error) Outcome { - return Outcome{Kind: OutcomeDomain, Status: status, Domain: name, Cause: cause, DiagnosticDelivery: DeliveryNone} +// Domain returns an application-domain outcome with the supplied name and status. +func Domain(name string, status int) Outcome { + return Outcome{Kind: OutcomeDomain, Status: status, Domain: name} } -// MessageDiagnostic returns a diagnostic that writes message followed by a -// newline. The writer's error is returned to the caller. +// WithExplanation returns a copy of the outcome with the supplied terminal explanation. +func (o Outcome) WithExplanation(message string, details ...Detail) Outcome { + o.Explanation = Explanation{Message: message, Details: cloneDetails(details)} + return o +} + +// WithCause returns a copy of the outcome with the supplied optional cause. +func (o Outcome) WithCause(cause error) Outcome { + o.Cause = cause + return o +} + +func cloneDetails(details []Detail) []Detail { + if details == nil { + return nil + } + return append([]Detail{}, details...) +} + +// MessageDiagnostic returns passive diagnostic data that writes message followed +// by a newline when submitted through a reporter. func MessageDiagnostic(severity Severity, message string) Diagnostic { return Diagnostic{ Severity: severity, diff --git a/outcome_test.go b/outcome_test.go index e056c9e..93c978d 100644 --- a/outcome_test.go +++ b/outcome_test.go @@ -2,39 +2,130 @@ package commandkit import ( "errors" + "io" + "strings" "testing" ) -func TestOutcomeConstructors(t *testing.T) { - cause := errors.New("operation failed") +type outcomeTestCause struct{ message string } + +func (e *outcomeTestCause) Error() string { return e.message } + +func TestOutcomeConstructorsAndModifiers(t *testing.T) { + var calls int + detail := Detail(func(io.Writer) error { + calls++ + return nil + }) + cause := &outcomeTestCause{message: "operation failed"} + wrapped := errors.Join(errors.New("context"), cause) + tests := []struct { - name string - got Outcome - kind OutcomeKind - status int - domain string - wantCause error + name string + got Outcome + kind OutcomeKind + status int + domain string }{ {name: "success", got: Success(), kind: OutcomeSuccess, status: 0}, - {name: "usage", got: Usage(cause), kind: OutcomeUsage, status: 2, wantCause: cause}, - {name: "failure", got: Failure(cause), kind: OutcomeOperational, status: 1, wantCause: cause}, - {name: "domain", got: Domain("record", 7, cause), kind: OutcomeDomain, status: 7, domain: "record", wantCause: cause}, + {name: "usage", got: Usage("invalid input", detail), kind: OutcomeUsage, status: 2}, + {name: "failure", got: Failure("operation failed", detail), kind: OutcomeOperational, status: 1}, + {name: "domain", got: Domain("record", 7), kind: OutcomeDomain, status: 7, domain: "record"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - if test.got.Kind != test.kind { - t.Fatalf("Kind = %q, want %q", test.got.Kind, test.kind) + if test.got.Kind != test.kind || test.got.Status != test.status || test.got.Domain != test.domain { + t.Fatalf("outcome = %#v, want kind %q, status %d, domain %q", test.got, test.kind, test.status, test.domain) } - if test.got.Status != test.status { - t.Fatalf("Status = %d, want %d", test.got.Status, test.status) + }) + } + if calls != 0 { + t.Fatalf("constructors called detail renderer %d times", calls) + } + + base := Domain("validation", 3).WithCause(wrapped) + updated := base.WithExplanation("invalid record", detail) + if updated.Kind != base.Kind || updated.Status != base.Status || updated.Domain != base.Domain { + t.Fatalf("WithExplanation changed classification: base %#v, updated %#v", base, updated) + } + if !errors.Is(updated.Cause, cause) { + t.Fatalf("WithExplanation lost cause identity: %v", updated.Cause) + } + var gotCause *outcomeTestCause + if !errors.As(updated.Cause, &gotCause) || gotCause != cause { + t.Fatalf("WithExplanation lost typed cause: %#v", gotCause) + } + if updated.Explanation.Message != "invalid record" || len(updated.Explanation.Details) != 1 { + t.Fatalf("WithExplanation = %#v", updated.Explanation) + } + if calls != 0 { + t.Fatalf("WithExplanation called detail renderer %d times", calls) + } + + changedCause := updated.WithCause(errors.New("replacement")) + if changedCause.Kind != updated.Kind || changedCause.Status != updated.Status || changedCause.Domain != updated.Domain { + t.Fatalf("WithCause changed classification: %#v", changedCause) + } + if changedCause.Explanation.Message != updated.Explanation.Message || len(changedCause.Explanation.Details) != 1 { + t.Fatalf("WithCause changed explanation: %#v", changedCause.Explanation) + } +} + +func TestOutcomeCopiesDetailSlices(t *testing.T) { + one := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "one"); return err }) + two := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "two"); return err }) + tests := []struct { + name string + make func([]Detail) Outcome + }{ + {name: "failure", make: func(details []Detail) Outcome { return Failure("failed", details...) }}, + {name: "usage", make: func(details []Detail) Outcome { return Usage("invalid", details...) }}, + {name: "modifier", make: func(details []Detail) Outcome { return Success().WithExplanation("failed", details...) }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + supplied := []Detail{one} + outcome := test.make(supplied) + supplied[0] = two + supplied = append(supplied, two) + if len(outcome.Explanation.Details) != 1 || outcome.Explanation.Details[0] == nil { + t.Fatalf("captured details = %#v", outcome.Explanation.Details) } - if test.got.Cause != test.wantCause { - t.Fatalf("Cause = %v, want original cause %v", test.got.Cause, test.wantCause) + var rendered strings.Builder + if err := outcome.Explanation.Details[0](&rendered); err != nil { + t.Fatal(err) } - if test.got.Domain != test.domain { - t.Fatalf("Domain = %q, want %q", test.got.Domain, test.domain) + if rendered.String() != "one" { + t.Fatalf("captured callback rendered %q, want original callback", rendered.String()) } }) } } + +func TestWithCausePreservesErrorIdentity(t *testing.T) { + cause := &outcomeTestCause{message: "typed failure"} + outcome := Failure("failed").WithCause(cause) + if !errors.Is(outcome.Cause, cause) { + t.Fatalf("errors.Is(%v, cause) = false", outcome.Cause) + } + var got *outcomeTestCause + if !errors.As(outcome.Cause, &got) || got != cause { + t.Fatalf("errors.As cause = %#v", got) + } +} + +func TestMessageDiagnosticIsPassiveData(t *testing.T) { + diagnostic := MessageDiagnostic(SeverityWarning, "review needed\n") + if diagnostic.Severity != SeverityWarning || diagnostic.Write == nil { + t.Fatalf("diagnostic = %#v", diagnostic) + } + var rendered strings.Builder + if err := diagnostic.Write(&rendered); err != nil { + t.Fatal(err) + } + if got := rendered.String(); got != "review needed\n" { + t.Fatalf("rendered diagnostic = %q", got) + } +}