Define invocation outcome reporting types
This commit is contained in:
@@ -70,7 +70,7 @@ are those selected in the roadmap, without runtime detection machinery.
|
|||||||
|
|
||||||
## Stage 1: Replace outcome and provider data declarations
|
## Stage 1: Replace outcome and provider data declarations
|
||||||
|
|
||||||
Status: pending.
|
Status: complete.
|
||||||
|
|
||||||
Primary files: [outcome.go](../../outcome.go) and their focused contract tests.
|
Primary files: [outcome.go](../../outcome.go) and their focused contract tests.
|
||||||
|
|
||||||
|
|||||||
149
outcome.go
149
outcome.go
@@ -1,18 +1,23 @@
|
|||||||
package commandkit
|
package commandkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Severity classifies a neutral diagnostic.
|
// Severity classifies a nonterminal diagnostic.
|
||||||
type Severity string
|
type Severity string
|
||||||
|
|
||||||
const (
|
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"
|
SeverityWarning Severity = "warning"
|
||||||
// SeverityError indicates a diagnostic associated with an error.
|
// SeverityDebug marks diagnostic detail for debug output.
|
||||||
SeverityError Severity = "error"
|
SeverityDebug Severity = "debug"
|
||||||
|
// SeverityProgress marks progress information.
|
||||||
|
SeverityProgress Severity = "progress"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OutcomeKind classifies the result of a command invocation.
|
// OutcomeKind classifies the result of a command invocation.
|
||||||
@@ -29,88 +34,134 @@ const (
|
|||||||
OutcomeDomain OutcomeKind = "domain"
|
OutcomeDomain OutcomeKind = "domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeliveryState describes whether CommandKit completed diagnostic delivery.
|
// DeliveryState describes the result of presenting content to a destination.
|
||||||
type DeliveryState string
|
type DeliveryState uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// DeliveryNone indicates that no diagnostic delivery was attempted.
|
// DeliveryNone indicates that no content was submitted to the destination.
|
||||||
DeliveryNone DeliveryState = "none"
|
DeliveryNone DeliveryState = iota
|
||||||
// DeliveryComplete indicates that diagnostic delivery completed.
|
// DeliverySuppressed indicates that all submitted content was intentionally hidden.
|
||||||
DeliveryComplete DeliveryState = "complete"
|
DeliverySuppressed
|
||||||
// DeliveryIncomplete indicates that diagnostic delivery did not complete.
|
// DeliveryComplete indicates that all requested delivery completed.
|
||||||
DeliveryIncomplete DeliveryState = "incomplete"
|
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
|
// Write renders the diagnostic to a supplied writer when the owning integration
|
||||||
// any error encountered while writing. A nil Write function has no output.
|
// submits it through the invocation reporter. A nil Write function has no detail.
|
||||||
type Diagnostic struct {
|
type Diagnostic struct {
|
||||||
// Severity classifies the diagnostic without depending on a provider library.
|
// Severity classifies the diagnostic without depending on a provider library.
|
||||||
Severity Severity
|
Severity Severity
|
||||||
// Write writes the diagnostic to the supplied writer.
|
// Write renders the diagnostic to the supplied writer.
|
||||||
Write func(io.Writer) error
|
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 {
|
type Acquired[T any] struct {
|
||||||
// Value is the value returned by the acquisition callback.
|
// Value is the value returned by the acquisition callback.
|
||||||
Value T
|
Value T
|
||||||
// Diagnostics are retained for delivery by the invocation coordinator.
|
// Diagnostics are passive nonterminal messages for the owning command to submit.
|
||||||
Diagnostics []Diagnostic
|
Diagnostics []Diagnostic
|
||||||
|
// Explanation provides terminal context if the owning command returns failure.
|
||||||
|
Explanation Explanation
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outcome is the classified result of an operation.
|
// Outcome is the classified result of an operation.
|
||||||
//
|
//
|
||||||
// Cause records the operation error. DeliveryError records an error encountered
|
// Cause records an optional operation error without selecting the outcome or
|
||||||
// while presenting diagnostics or reports; it is kept separate from Cause so
|
// generating operator-facing text. Presentation records delivery observations
|
||||||
// both failures remain inspectable. Outcome does not implement error.
|
// separately. Invocation coordination treats a zero Outcome as success with status 0.
|
||||||
// Invocation coordination treats a zero Outcome as success with status 0.
|
|
||||||
type Outcome struct {
|
type Outcome struct {
|
||||||
// Kind classifies the result.
|
// Kind classifies the result.
|
||||||
Kind OutcomeKind
|
Kind OutcomeKind
|
||||||
// Status is the suggested process status for this result.
|
// Status is the suggested process status for this result.
|
||||||
Status int
|
Status int
|
||||||
// Domain names the application-defined classification when Kind is
|
// Domain names the application-defined classification when Kind is OutcomeDomain.
|
||||||
// OutcomeDomain.
|
|
||||||
Domain string
|
Domain string
|
||||||
// Cause is the error returned by the operation.
|
// Cause is the optional operation error, preserved for caller inspection.
|
||||||
Cause error
|
Cause error
|
||||||
// DeliveryError is an error encountered while writing a report or
|
// Explanation contains terminal content for a failed outcome.
|
||||||
// diagnostics.
|
Explanation Explanation
|
||||||
DeliveryError error
|
// Presentation records delivery observations populated by CommandKit.
|
||||||
// DiagnosticDelivery records whether diagnostic writing completed. Its zero
|
Presentation PresentationResult
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success returns a successful outcome with status 0.
|
// Success returns a successful outcome with status 0.
|
||||||
func Success() Outcome {
|
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.
|
// Failure returns an operational outcome with status 1 and a terminal explanation.
|
||||||
func Usage(cause error) Outcome {
|
func Failure(message string, details ...Detail) Outcome {
|
||||||
return Outcome{Kind: OutcomeUsage, Status: 2, Cause: cause, DiagnosticDelivery: DeliveryNone}
|
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.
|
// Usage returns a usage outcome with status 2 and a terminal explanation.
|
||||||
func Failure(cause error) Outcome {
|
func Usage(message string, details ...Detail) Outcome {
|
||||||
return Outcome{Kind: OutcomeOperational, Status: 1, Cause: cause, DiagnosticDelivery: DeliveryNone}
|
return Outcome{Kind: OutcomeUsage, Status: 2, Explanation: Explanation{Message: message, Details: cloneDetails(details)}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Domain returns an application-domain outcome preserving the supplied name,
|
// Domain returns an application-domain outcome with the supplied name and status.
|
||||||
// status, and cause.
|
func Domain(name string, status int) Outcome {
|
||||||
func Domain(name string, status int, cause error) Outcome {
|
return Outcome{Kind: OutcomeDomain, Status: status, Domain: name}
|
||||||
return Outcome{Kind: OutcomeDomain, Status: status, Domain: name, Cause: cause, DiagnosticDelivery: DeliveryNone}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageDiagnostic returns a diagnostic that writes message followed by a
|
// WithExplanation returns a copy of the outcome with the supplied terminal explanation.
|
||||||
// newline. The writer's error is returned to the caller.
|
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 {
|
func MessageDiagnostic(severity Severity, message string) Diagnostic {
|
||||||
return Diagnostic{
|
return Diagnostic{
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
|
|||||||
129
outcome_test.go
129
outcome_test.go
@@ -2,39 +2,130 @@ package commandkit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOutcomeConstructors(t *testing.T) {
|
type outcomeTestCause struct{ message string }
|
||||||
cause := errors.New("operation failed")
|
|
||||||
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
got Outcome
|
got Outcome
|
||||||
kind OutcomeKind
|
kind OutcomeKind
|
||||||
status int
|
status int
|
||||||
domain string
|
domain string
|
||||||
wantCause error
|
|
||||||
}{
|
}{
|
||||||
{name: "success", got: Success(), kind: OutcomeSuccess, status: 0},
|
{name: "success", got: Success(), kind: OutcomeSuccess, status: 0},
|
||||||
{name: "usage", got: Usage(cause), kind: OutcomeUsage, status: 2, wantCause: cause},
|
{name: "usage", got: Usage("invalid input", detail), kind: OutcomeUsage, status: 2},
|
||||||
{name: "failure", got: Failure(cause), kind: OutcomeOperational, status: 1, wantCause: cause},
|
{name: "failure", got: Failure("operation failed", detail), kind: OutcomeOperational, status: 1},
|
||||||
{name: "domain", got: Domain("record", 7, cause), kind: OutcomeDomain, status: 7, domain: "record", wantCause: cause},
|
{name: "domain", got: Domain("record", 7), kind: OutcomeDomain, status: 7, domain: "record"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
if test.got.Kind != test.kind {
|
if test.got.Kind != test.kind || test.got.Status != test.status || test.got.Domain != test.domain {
|
||||||
t.Fatalf("Kind = %q, want %q", test.got.Kind, test.kind)
|
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 {
|
var rendered strings.Builder
|
||||||
t.Fatalf("Cause = %v, want original cause %v", test.got.Cause, test.wantCause)
|
if err := outcome.Explanation.Details[0](&rendered); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if test.got.Domain != test.domain {
|
if rendered.String() != "one" {
|
||||||
t.Fatalf("Domain = %q, want %q", test.got.Domain, test.domain)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user