Add invocation coordination and diagnostics
This commit is contained in:
@@ -99,6 +99,7 @@ type commandPayload interface {
|
|||||||
hasRun() bool
|
hasRun() bool
|
||||||
bridgeFlags() []internalcobra.Flag
|
bridgeFlags() []internalcobra.Flag
|
||||||
prepare([][]string, []string, bool) (func() any, error)
|
prepare([][]string, []string, bool) (func() any, error)
|
||||||
|
checkInput(any) error
|
||||||
run(Invocation, any) Outcome
|
run(Invocation, any) Outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +147,13 @@ func (c typedCommand[I]) run(invocation Invocation, input any) Outcome {
|
|||||||
return c.spec.Run(invocation, *input.(*I))
|
return c.spec.Run(invocation, *input.(*I))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c typedCommand[I]) checkInput(input any) error {
|
||||||
|
if c.spec.CheckInput == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.spec.CheckInput(*input.(*I))
|
||||||
|
}
|
||||||
|
|
||||||
// NewCommand validates and creates a typed command declaration. It retains
|
// NewCommand validates and creates a typed command declaration. It retains
|
||||||
// callbacks without invoking them and copies the declarative flag and argument
|
// callbacks without invoking them and copies the declarative flag and argument
|
||||||
// slices.
|
// slices.
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
CommandKit currently contains adopted repository policies, accepted architectural
|
CommandKit currently contains adopted repository policies, accepted architectural
|
||||||
decisions, implementation plans, identity and outcome primitives, typed command
|
decisions, implementation plans, identity and outcome primitives, typed command
|
||||||
declarations, an instance-owned registry, and a Cobra-backed routing and parsing
|
declarations, an instance-owned registry, and a Cobra-backed routing and parsing
|
||||||
adapter. Public invocation coordination is not implemented yet. The
|
adapter, plus public `App.Execute` coordination for custom commands. Generated
|
||||||
|
help/version rendering and standard command families remain pending. The
|
||||||
[README](../README.md) provides project orientation; the
|
[README](../README.md) provides project orientation; the
|
||||||
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
||||||
canonical documentation owners.
|
canonical documentation owners.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Initial CommandKit implementation plan
|
# Initial CommandKit implementation plan
|
||||||
|
|
||||||
Status: implementation underway; Stages 1-4 complete and Stages 5-14 pending.
|
Status: implementation underway; Stages 1-5 complete and Stages 6-14 pending.
|
||||||
|
|
||||||
Date: 2026-10-07.
|
Date: 2026-10-07.
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ only the internal adapter and not Cobra or pflag.
|
|||||||
|
|
||||||
## Stage 5: implement invocation and diagnostic delivery
|
## Stage 5: implement invocation and diagnostic delivery
|
||||||
|
|
||||||
**Status:** pending. **Prerequisite:** Stage 4.
|
**Status:** complete. **Prerequisite:** Stage 4.
|
||||||
|
|
||||||
Add `func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome`.
|
Add `func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome`.
|
||||||
It consumes argv without the executable name. Use the supplied nonnil context;
|
It consumes argv without the executable name. Use the supplied nonnil context;
|
||||||
@@ -393,6 +393,12 @@ warning-only success; diagnostic failure after operation failure retains both;
|
|||||||
report failure marks incomplete delivery; no duplicate root diagnostics; no process
|
report failure marks incomplete delivery; no duplicate root diagnostics; no process
|
||||||
exit. Include a consecutive run that omits previously supplied flags/operands.
|
exit. Include a consecutive run that omits previously supplied flags/operands.
|
||||||
|
|
||||||
|
**Completion evidence:** external-package tests exercise real parsing and dispatch,
|
||||||
|
typed state freshness, usage and input-check causes, domain outcomes, warning-only
|
||||||
|
success, completed diagnostic suppression, checked short writes, and combined
|
||||||
|
operation/delivery failures. `go test ./...` passes. Report-writing implementations
|
||||||
|
will set `ReportIncomplete` when introduced with the standard reports.
|
||||||
|
|
||||||
## Stage 6: complete help and version
|
## Stage 6: complete help and version
|
||||||
|
|
||||||
**Status:** pending. **Prerequisite:** Stage 5.
|
**Status:** pending. **Prerequisite:** Stage 5.
|
||||||
|
|||||||
180
execute.go
Normal file
180
execute.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Execute parses and dispatches one invocation. args excludes the executable
|
||||||
|
// name. CommandKit writes diagnostics to streams.Err and leaves process exit to
|
||||||
|
// the caller. A nil context returns an operational failure, and nil streams are
|
||||||
|
// replaced with io.Discard.
|
||||||
|
func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome {
|
||||||
|
if ctx == nil {
|
||||||
|
return a.present(Failure(errors.New("invocation context must not be nil")), nil, streams)
|
||||||
|
}
|
||||||
|
streams = normalizeStreams(streams)
|
||||||
|
parsed := a.parse(args, internalcobra.ParseOptions{
|
||||||
|
Context: ctx,
|
||||||
|
Out: streams.Out,
|
||||||
|
Err: streams.Err,
|
||||||
|
})
|
||||||
|
if parsed.Failure != nil {
|
||||||
|
return a.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path, streams)
|
||||||
|
}
|
||||||
|
command, ok := a.commandAt(parsed.Path)
|
||||||
|
if !ok || command.payload == nil {
|
||||||
|
// Help rendering is completed in Stage 6. The dispatch boundary already
|
||||||
|
// resolves metadata requests without constructing handler input.
|
||||||
|
return Success()
|
||||||
|
}
|
||||||
|
if parsed.HelpRequested || !command.payload.hasRun() {
|
||||||
|
if _, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
|
||||||
|
return a.present(Usage(err), parsed.Path, streams)
|
||||||
|
}
|
||||||
|
return Success()
|
||||||
|
}
|
||||||
|
buildInput, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, true)
|
||||||
|
if err != nil {
|
||||||
|
return a.present(Usage(err), parsed.Path, streams)
|
||||||
|
}
|
||||||
|
input := buildInput()
|
||||||
|
if err := command.payload.checkInput(input); err != nil {
|
||||||
|
return a.present(Usage(err), parsed.Path, streams)
|
||||||
|
}
|
||||||
|
outcome := command.payload.run(Invocation{Context: ctx, Streams: streams}, input)
|
||||||
|
return a.present(outcome, parsed.Path, streams)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStreams(streams Streams) Streams {
|
||||||
|
if streams.Out == nil {
|
||||||
|
streams.Out = io.Discard
|
||||||
|
}
|
||||||
|
if streams.Err == nil {
|
||||||
|
streams.Err = io.Discard
|
||||||
|
}
|
||||||
|
return streams
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) commandAt(path []string) (Command, bool) {
|
||||||
|
if a == nil {
|
||||||
|
return Command{}, false
|
||||||
|
}
|
||||||
|
if len(path) == 0 {
|
||||||
|
return Command{}, true
|
||||||
|
}
|
||||||
|
command, ok := a.commands[path[0]]
|
||||||
|
if !ok || command.reserved {
|
||||||
|
return Command{}, false
|
||||||
|
}
|
||||||
|
current := command.command
|
||||||
|
for _, name := range path[1:] {
|
||||||
|
found := false
|
||||||
|
for _, child := range current.children {
|
||||||
|
if child.name == name {
|
||||||
|
current = child
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return Command{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) present(outcome Outcome, path []string, streams Streams) Outcome {
|
||||||
|
streams = normalizeStreams(streams)
|
||||||
|
if outcome.DiagnosticDelivery == DeliveryComplete {
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
diagnostics := append([]Diagnostic(nil), outcome.Diagnostics...)
|
||||||
|
if outcome.Cause != nil && !hasErrorDiagnostic(diagnostics) {
|
||||||
|
diagnostics = append(diagnostics, MessageDiagnostic(configkit.SeverityError, outcome.Cause.Error()))
|
||||||
|
}
|
||||||
|
if outcome.Kind == OutcomeUsage {
|
||||||
|
diagnostics = append(diagnostics, MessageDiagnostic(configkit.SeverityError, a.usageLine(path)))
|
||||||
|
}
|
||||||
|
outcome.Diagnostics = diagnostics
|
||||||
|
if len(diagnostics) == 0 {
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
for _, diagnostic := range diagnostics {
|
||||||
|
if diagnostic.Write == nil {
|
||||||
|
outcome = deliveryFailure(outcome, errors.New("diagnostic has no writer"))
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
writer := &checkedWriter{writer: streams.Err}
|
||||||
|
writeErr := diagnostic.Write(writer)
|
||||||
|
if writer.failure != nil {
|
||||||
|
if writeErr == nil {
|
||||||
|
writeErr = writer.failure
|
||||||
|
} else if !errors.Is(writeErr, writer.failure) {
|
||||||
|
writeErr = errors.Join(writeErr, writer.failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if writeErr != nil {
|
||||||
|
return deliveryFailure(outcome, writeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome.DiagnosticDelivery = DeliveryComplete
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) usageLine(path []string) string {
|
||||||
|
name := "command"
|
||||||
|
if a != nil && a.identity.Name != "" {
|
||||||
|
name = a.identity.Name
|
||||||
|
}
|
||||||
|
parts := append([]string{name}, path...)
|
||||||
|
if len(path) == 0 {
|
||||||
|
return fmt.Sprintf("Usage: %s [command]", name)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Usage: %s [flags]", strings.Join(parts, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasErrorDiagnostic(diagnostics []Diagnostic) bool {
|
||||||
|
for _, diagnostic := range diagnostics {
|
||||||
|
if diagnostic.Severity == configkit.SeverityError {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliveryFailure(outcome Outcome, err error) Outcome {
|
||||||
|
if outcome.DeliveryError == nil {
|
||||||
|
outcome.DeliveryError = err
|
||||||
|
} else {
|
||||||
|
outcome.DeliveryError = errors.Join(outcome.DeliveryError, err)
|
||||||
|
}
|
||||||
|
outcome.DiagnosticDelivery = DeliveryIncomplete
|
||||||
|
if outcome.Kind != OutcomeDomain {
|
||||||
|
outcome.Kind = OutcomeOperational
|
||||||
|
outcome.Status = 1
|
||||||
|
}
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkedWriter struct {
|
||||||
|
writer io.Writer
|
||||||
|
failure error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *checkedWriter) Write(data []byte) (int, error) {
|
||||||
|
n, err := w.writer.Write(data)
|
||||||
|
if err == nil && n < len(data) {
|
||||||
|
err = io.ErrShortWrite
|
||||||
|
}
|
||||||
|
if err != nil && w.failure == nil {
|
||||||
|
w.failure = err
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
264
execute_external_test.go
Normal file
264
execute_external_test.go
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
package commandkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||||
|
type input struct {
|
||||||
|
name commandkit.Supplied[string]
|
||||||
|
ids commandkit.Supplied[[]string]
|
||||||
|
}
|
||||||
|
var inputs []input
|
||||||
|
var contexts []context.Context
|
||||||
|
var streams []commandkit.Streams
|
||||||
|
ctx := context.WithValue(context.Background(), struct{}{}, "invocation")
|
||||||
|
var out, diagnostic bytes.Buffer
|
||||||
|
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||||
|
Name: "run",
|
||||||
|
Flags: []commandkit.Flag[input]{
|
||||||
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.name = value }),
|
||||||
|
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "id"}, nil, func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[[]string]) { target.ids = value }),
|
||||||
|
},
|
||||||
|
Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome {
|
||||||
|
inputs = append(inputs, value)
|
||||||
|
contexts = append(contexts, invocation.Context)
|
||||||
|
streams = append(streams, invocation.Streams)
|
||||||
|
return commandkit.Success()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := app.Execute(ctx, []string{"run", "--name=first", "--id=a,b", "--id=c"}, commandkit.Streams{Out: &out, Err: &diagnostic})
|
||||||
|
second := app.Execute(ctx, []string{"run"}, commandkit.Streams{Out: &out, Err: &diagnostic})
|
||||||
|
third := app.Execute(ctx, []string{"run"}, commandkit.Streams{})
|
||||||
|
if first.Kind != commandkit.OutcomeSuccess || second.Kind != commandkit.OutcomeSuccess || third.Kind != commandkit.OutcomeSuccess {
|
||||||
|
t.Fatalf("outcomes = %#v, %#v, and %#v", first, second, third)
|
||||||
|
}
|
||||||
|
if len(inputs) != 3 || inputs[0].name.Value != "first" || !inputs[0].name.Set || len(inputs[0].ids.Value) != 2 || inputs[0].ids.Value[0] != "a,b" || inputs[0].ids.Value[1] != "c" {
|
||||||
|
t.Fatalf("first input = %#v", inputs)
|
||||||
|
}
|
||||||
|
if inputs[1].name.Set || inputs[1].name.Value != "" || inputs[1].ids.Set {
|
||||||
|
t.Fatalf("second invocation inherited state: %#v", inputs[1])
|
||||||
|
}
|
||||||
|
if streams[2].Out != io.Discard || streams[2].Err != io.Discard {
|
||||||
|
t.Fatalf("nil streams were not replaced with io.Discard: %#v", streams[2])
|
||||||
|
}
|
||||||
|
if contexts[0] != ctx || streams[0].Out != &out || streams[0].Err != &diagnostic {
|
||||||
|
t.Fatalf("handler invocation context or streams were not forwarded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
|
||||||
|
type input struct{ count commandkit.Supplied[int] }
|
||||||
|
parseFailure := errors.New("bad count")
|
||||||
|
checkFailure := errors.New("incompatible inputs")
|
||||||
|
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||||
|
Name: "run",
|
||||||
|
Flags: []commandkit.Flag[input]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Required: true}, nil, func(value string) (int, error) {
|
||||||
|
if value == "bad" {
|
||||||
|
return 0, parseFailure
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}, func(target *input, value commandkit.Supplied[int]) { target.count = value })},
|
||||||
|
Arguments: []commandkit.Argument[input]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(*input, commandkit.Supplied[string]) {})},
|
||||||
|
CheckInput: func(input) error { return checkFailure },
|
||||||
|
Run: func(commandkit.Invocation, input) commandkit.Outcome { return commandkit.Success() },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var diagnostic bytes.Buffer
|
||||||
|
missing := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||||
|
if missing.Kind != commandkit.OutcomeUsage || missing.Status != 2 || !strings.Contains(diagnostic.String(), "Usage: example run") {
|
||||||
|
t.Fatalf("missing required input = %#v, diagnostics %q", missing, diagnostic.String())
|
||||||
|
}
|
||||||
|
malformed := app.Execute(context.Background(), []string{"run", "--count=bad", "id"}, commandkit.Streams{Err: &diagnostic})
|
||||||
|
if malformed.Kind != commandkit.OutcomeUsage || !errors.Is(malformed.Cause, parseFailure) {
|
||||||
|
t.Fatalf("parser failure = %#v", malformed)
|
||||||
|
}
|
||||||
|
checked := app.Execute(context.Background(), []string{"run", "--count=1", "id"}, commandkit.Streams{Err: &diagnostic})
|
||||||
|
if checked.Kind != commandkit.OutcomeUsage || !errors.Is(checked.Cause, checkFailure) {
|
||||||
|
t.Fatalf("input check failure = %#v", checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||||
|
domainCause := errors.New("domain rejected the request")
|
||||||
|
warning := commandkit.MessageDiagnostic(configkit.SeverityWarning, "review needed")
|
||||||
|
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
|
outcome := commandkit.Domain("sample", 2, domainCause)
|
||||||
|
outcome.Diagnostics = []commandkit.Diagnostic{warning}
|
||||||
|
return outcome
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var diagnostic bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||||
|
if outcome.Kind != commandkit.OutcomeDomain || outcome.Domain != "sample" || outcome.Status != 2 || !errors.Is(outcome.Cause, domainCause) {
|
||||||
|
t.Fatalf("domain outcome changed: %#v", outcome)
|
||||||
|
}
|
||||||
|
if diagnostic.String() != "review needed\ndomain rejected the request\n" || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
||||||
|
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
|
||||||
|
}
|
||||||
|
warningOnly, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
|
Name: "warn",
|
||||||
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
|
result := commandkit.Success()
|
||||||
|
result.Diagnostics = []commandkit.Diagnostic{warning}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
diagnostic.Reset()
|
||||||
|
outcome = app.Execute(context.Background(), []string{"warn"}, commandkit.Streams{Err: &diagnostic})
|
||||||
|
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "review needed\n" {
|
||||||
|
t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T) {
|
||||||
|
cause := errors.New("already shown")
|
||||||
|
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
|
outcome := commandkit.Domain("sample", 2, cause)
|
||||||
|
outcome.Diagnostics = []commandkit.Diagnostic{{Severity: configkit.SeverityError, Write: func(writer io.Writer) error {
|
||||||
|
_, err := io.WriteString(writer, "presented\n")
|
||||||
|
return err
|
||||||
|
}}}
|
||||||
|
return outcome
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var diagnostic bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||||
|
if outcome.Kind != commandkit.OutcomeDomain || diagnostic.String() != "presented\n" {
|
||||||
|
t.Fatalf("presented cause was duplicated: %#v, %q", outcome, diagnostic.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostic.Reset()
|
||||||
|
completed := commandkit.Domain("sample", 2, cause)
|
||||||
|
completed.DiagnosticDelivery = commandkit.DeliveryComplete
|
||||||
|
command, err = commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "done", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return completed }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app, err = commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outcome = app.Execute(context.Background(), []string{"done"}, commandkit.Streams{Err: &diagnostic})
|
||||||
|
if diagnostic.Len() != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
||||||
|
t.Fatalf("completed diagnostics were repeated: %#v, %q", outcome, diagnostic.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
||||||
|
operationFailure := context.Canceled
|
||||||
|
writeFailure := errors.New("writer failed")
|
||||||
|
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
|
outcome := commandkit.Failure(operationFailure)
|
||||||
|
outcome.ReportIncomplete = true
|
||||||
|
return outcome
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{err: writeFailure}})
|
||||||
|
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.DeliveryError, writeFailure) {
|
||||||
|
t.Fatalf("combined operation and delivery failure = %#v", outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
short := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: shortWriter{}})
|
||||||
|
if !errors.Is(short.DeliveryError, io.ErrShortWrite) || short.DiagnosticDelivery != commandkit.DeliveryIncomplete {
|
||||||
|
t.Fatalf("short write was not detected: %#v", short)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) {
|
||||||
|
type input struct{ id commandkit.Supplied[string] }
|
||||||
|
factoryCalls, checkCalls, runCalls := 0, 0, 0
|
||||||
|
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||||
|
Name: "run",
|
||||||
|
NewInput: func() input {
|
||||||
|
factoryCalls++
|
||||||
|
return input{}
|
||||||
|
},
|
||||||
|
Arguments: []commandkit.Argument[input]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.id = value })},
|
||||||
|
CheckInput: func(input) error { checkCalls++; return nil },
|
||||||
|
Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
help := app.Execute(context.Background(), []string{"run", "--help"}, commandkit.Streams{})
|
||||||
|
if help.Kind != commandkit.OutcomeSuccess || factoryCalls != 0 || checkCalls != 0 || runCalls != 0 {
|
||||||
|
t.Fatalf("help request invoked execution path: %#v calls=%d/%d/%d", help, factoryCalls, checkCalls, runCalls)
|
||||||
|
}
|
||||||
|
invalid := app.Execute(nil, []string{"run"}, commandkit.Streams{})
|
||||||
|
if invalid.Kind != commandkit.OutcomeOperational || invalid.Status != 1 || invalid.Cause == nil {
|
||||||
|
t.Fatalf("nil context outcome = %#v", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingWriter struct{ err error }
|
||||||
|
|
||||||
|
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||||
|
|
||||||
|
type shortWriter struct{}
|
||||||
|
|
||||||
|
func (shortWriter) Write(data []byte) (int, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return len(data) - 1, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user