From c3448a2ebee0930ca27fe1d078f78f2ef9efd655 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 13:30:24 +0000 Subject: [PATCH] Add invocation coordination and diagnostics --- command.go | 8 + docs/development.md | 3 +- docs/roadmap/implementation.md | 10 +- execute.go | 180 ++++++++++++++++++++++ execute_external_test.go | 264 +++++++++++++++++++++++++++++++++ 5 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 execute.go create mode 100644 execute_external_test.go diff --git a/command.go b/command.go index 368fe4f..e544303 100644 --- a/command.go +++ b/command.go @@ -99,6 +99,7 @@ type commandPayload interface { hasRun() bool bridgeFlags() []internalcobra.Flag prepare([][]string, []string, bool) (func() any, error) + checkInput(any) error 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)) } +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 // callbacks without invoking them and copies the declarative flag and argument // slices. diff --git a/docs/development.md b/docs/development.md index 0916d6e..ae3de48 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,7 +3,8 @@ CommandKit currently contains adopted repository policies, accepted architectural decisions, implementation plans, identity and outcome primitives, typed command 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 [documentation policy](policy/documentation.md#canonical-ownership) assigns canonical documentation owners. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index f200bdf..62a4ec3 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # 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. @@ -346,7 +346,7 @@ only the internal adapter and not Cobra or pflag. ## 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`. 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 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 **Status:** pending. **Prerequisite:** Stage 5. diff --git a/execute.go b/execute.go new file mode 100644 index 0000000..4fee0fa --- /dev/null +++ b/execute.go @@ -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 +} diff --git a/execute_external_test.go b/execute_external_test.go new file mode 100644 index 0000000..ff5eb0d --- /dev/null +++ b/execute_external_test.go @@ -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 +}