96 Commits

Author SHA1 Message Date
230187296f Stop flag interpretation at unknown flags 2026-10-26 14:37:33 +00:00
473fb027a1 Avoid retaining hidden simple deferred messages 2026-10-26 14:35:44 +00:00
135d8da2b9 Resolve flag aliases in failed command routes 2026-10-26 14:33:31 +00:00
de2266af75 Restore cancellation checks before report delivery 2026-10-26 14:32:03 +00:00
13a4d4eacd Explain invocation presentation failures 2026-10-26 14:28:58 +00:00
215fc34d51 Preserve joined rendering error evidence 2026-10-26 14:27:24 +00:00
3338b902a2 Present native PromptKit operation failures 2026-10-26 14:18:21 +00:00
41c6ca8070 Record reporting efficiency opportunities 2026-10-26 14:14:50 +00:00
ee76b0f5f1 Record reporting integration audit findings 2026-10-26 14:01:28 +00:00
1e532cdfa5 Record reporting audit findings 2026-10-26 12:41:36 +00:00
7c035ccc54 Validate invocation reporting implementation 2026-10-26 03:59:28 +00:00
96aa6b5ca0 Document invocation reporting contracts 2026-10-26 03:57:11 +00:00
a6c0fb5b0d Reconcile reporting tests and boundaries 2026-10-26 03:51:14 +00:00
6264a87183 Migrate maintained examples to invocation reporting 2026-10-26 03:44:19 +00:00
b6f22462d3 Add a core reporting example 2026-10-26 03:38:48 +00:00
401232a1b0 Migrate PromptKit validation and smoke reporting 2026-10-26 03:35:39 +00:00
4ace616bc2 Fix PromptKit inspection reporter assertions 2026-10-26 03:34:50 +00:00
7c010e945a Update requirements inspection reporting tests 2026-10-26 03:31:06 +00:00
7fc89768ef Migrate PromptKit inspection reports to the reporter 2026-10-26 03:30:19 +00:00
37dfea5b25 Separate PromptKit messages from terminal context 2026-10-26 03:26:14 +00:00
c0e0010683 Migrate ConfigKit reports to the reporter 2026-10-26 03:20:17 +00:00
5f37aac70f Render ConfigKit show and validate through reporter 2026-10-26 03:16:02 +00:00
9a4d1db955 Separate ConfigKit messages from explanations 2026-10-26 03:10:18 +00:00
f3e9326544 Add debug events to command execution 2026-10-26 03:03:57 +00:00
7a1067d387 Route help and version through the reporter 2026-10-26 03:02:08 +00:00
49a10690c6 Connect invocation execution to one reporter 2026-10-26 02:59:28 +00:00
df21b75f9c Preserve reporting flag occurrence order 2026-10-26 02:55:08 +00:00
f3bd6dc2ae Install shared reporting flags 2026-10-26 02:50:54 +00:00
6ba9338c0a Add deferred reporting finalization 2026-10-26 02:44:41 +00:00
5df88a6d94 Add atomic reporting and message categories 2026-10-26 02:42:22 +00:00
f5a5bbac58 Add managed reporter output state 2026-10-26 02:39:16 +00:00
e56537690b Define invocation outcome reporting types 2026-10-26 02:37:19 +00:00
2a1096bd1f Plan invocation reporting implementation 2026-10-26 01:39:40 +00:00
1d29cdd029 Clarify provider diagnostics and reporting edge cases 2026-10-26 01:25:11 +00:00
e48187d822 Accept invocation reporting architecture and clarify error ownership 2026-10-26 01:18:28 +00:00
e951a48902 Specify invocation reporting API signatures 2026-10-26 00:10:36 +00:00
976ed89403 Clarify reporting lifecycle and concurrency contracts 2026-10-26 00:00:25 +00:00
a5a92c7f08 Settle reporting delivery lifecycle and quiet rendering 2026-10-24 02:01:59 +00:00
514c4d47bb Settle failure presentation and reporting retention boundaries 2026-10-24 01:56:41 +00:00
ec29d658b7 Clarify application error handling and reporting ownership 2026-10-24 01:50:38 +00:00
085c296d89 Define terminal failure presentation for invocation reporting 2026-10-24 01:19:16 +00:00
0869b17b43 Select unified invocation reporting API direction 2026-10-24 01:01:48 +00:00
810333e376 Record progress controls and reporting prefixes 2026-10-24 00:42:35 +00:00
2e337155ce Settle reporting flag grammar and core debug guidance 2026-10-23 18:42:21 +00:00
fa0aeace36 Expand reporting categories and define failure-bound errors 2026-10-23 18:08:13 +00:00
09d4d7992a Draft output policy and invocation reporting roadmap 2026-10-23 14:08:10 +00:00
c70e29d3fd Remove completed alias interaction remediation document 2026-10-20 04:12:02 +00:00
e47ae799d7 Keep empty and dash operands with runnable parents 2026-10-20 01:07:23 +00:00
5c26d80228 Retain discovery prefixes in usage diagnostics 2026-10-20 01:04:56 +00:00
bafd365115 Discover help routes through intermediate aliases 2026-10-20 01:03:54 +00:00
4ddf960836 Record alias interaction audit findings 2026-10-20 00:39:24 +00:00
08ad7427e2 Remove completed alias planning and remediation documents 2026-10-20 00:33:38 +00:00
f42aefe02d Reconcile alias documentation with the completed implementation 2026-10-20 00:30:53 +00:00
520d7e125b Retain entered flag spellings in parser failures 2026-10-20 00:29:49 +00:00
eb92f7be7f Preserve double dash values during flag alias parsing 2026-10-20 00:28:38 +00:00
744b8135f2 Show aliases beneath empty command groups 2026-10-20 00:26:07 +00:00
c231a41665 Compose discovery help through group aliases 2026-10-20 00:25:33 +00:00
fa09edf8cc Require exact resolved paths for explicit help 2026-10-20 00:23:19 +00:00
7dad20680e Keep command routing independent of operand counts 2026-10-20 00:22:25 +00:00
ccf8acd861 Protect canonical operand prefixes from aliases 2026-10-20 00:20:40 +00:00
c225a66c97 Record alias implementation review findings 2026-10-20 00:03:54 +00:00
a54a9354b1 Verify and complete alias implementation 2026-10-19 20:02:07 +00:00
42d9989533 Prepare core alias release notes 2026-10-19 20:00:09 +00:00
333a630cf1 Document application-owned alias contracts 2026-10-19 19:59:41 +00:00
666c006a89 Add aliases to maintained examples 2026-10-19 19:56:33 +00:00
c3bb3941d3 Verify typed alias composition in integrations 2026-10-19 19:53:33 +00:00
f34ab12ca5 Verify public alias registration flows 2026-10-19 19:48:57 +00:00
1fa026e5bd Preserve alias context in usage errors 2026-10-19 19:46:22 +00:00
a504efbad7 Render help through command aliases 2026-10-19 19:40:55 +00:00
451a0c483f Expose canonical paths on command invocations 2026-10-19 19:33:49 +00:00
75fc45f072 Interpret command flag aliases in the parser 2026-10-19 19:31:23 +00:00
58b71c390f Resolve entered command routes to canonical paths 2026-10-19 17:37:59 +00:00
6ea8c4dd3f Compile effective flag aliases during installation 2026-10-19 17:33:39 +00:00
2ab87e9333 Install command aliases atomically 2026-10-19 17:27:17 +00:00
7a4efddab8 Add captured alias declaration types 2026-10-19 17:23:32 +00:00
854426a0c9 Validate command alias termination 2026-10-19 15:16:07 +00:00
80d4d6e1bd Model command alias prefix rewriting 2026-10-19 15:11:43 +00:00
b3fb795224 Add shared command path primitives 2026-10-19 15:08:00 +00:00
d7cc12a347 Plan application alias implementation 2026-10-19 14:41:10 +00:00
f3b8a129d9 Correct alias examples to respect smoke-test operand grammar 2026-10-19 14:26:49 +00:00
fae8c4dafa Accept ADR 0013 for application alias routing 2026-10-19 14:21:12 +00:00
ca85d5b012 Record future shell completion direction for aliases 2026-10-19 14:16:05 +00:00
93ee328779 Clarify alias routing interactions and implementation checks 2026-10-19 14:06:10 +00:00
c6c4b01902 Resolve alias declaration API and shared path handling 2026-10-19 13:48:07 +00:00
55330d2817 Settle short alias grammar and empty-value correction 2026-10-19 12:19:30 +00:00
fbef0f2ecc Record flag alias chains short spellings and help decisions 2026-10-19 01:44:38 +00:00
573a22a38e Record flag alias scope and target availability decisions 2026-10-19 01:21:33 +00:00
f526198059 Record alias invocation metadata decision 2026-10-19 01:18:13 +00:00
4b53c96cf3 Record alias help and discovery decisions 2026-10-19 01:16:50 +00:00
50c54c229e Record alias chain and group composition decisions 2026-10-19 00:20:47 +00:00
0dfeac3022 Record alias prefix and operand collision decisions 2026-10-19 00:19:28 +00:00
8d5966a868 Record alias registration conventions in roadmap 2026-10-18 23:51:39 +00:00
a4cf55f7a5 Record canonical collision and deterministic alias rules 2026-10-18 21:12:53 +00:00
1f3d1594c9 Add flag alias direction to alias roadmap 2026-10-18 16:09:15 +00:00
ae6af1dc65 Record application alias routing roadmap 2026-10-18 15:40:31 +00:00
df6ed89a97 Record published dependencies for ckprompt 2026-10-18 13:54:21 +00:00
99 changed files with 12699 additions and 1242 deletions

View File

@@ -8,13 +8,17 @@ modules, so applications choose which upstream libraries they add.
The core captures reusable command declarations, validates installation batches
before changing an application's registry, converts typed inputs before
constructing them, and returns outcomes without exiting the process. Its
[consumer guide](docs/consumers/guide.md) covers declaration and invocation.
[consumer guide](docs/consumers/guide.md) covers declaration, invocation
reporting, and migration. Each handler receives one invocation reporter for
substantive output, categorized messages, and terminal explanations; `Execute`
returns delivery observations with the outcome. The [CLI reference](docs/cli.md)
defines the shared visibility policy.
## Modules
| Module | Package | Provides |
| --- | --- | --- |
| `gitea.maximumdirect.net/eric/CommandKit` | `commandkit` | Typed commands, bindings, execution, outcomes, help, and version. |
| `gitea.maximumdirect.net/eric/CommandKit` | `commandkit` | Typed commands, bindings, execution reporting and outcomes, help, and version. |
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. |
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | `ckprompt` | PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, concrete request validation, and native smoke tests. |
@@ -25,7 +29,7 @@ integration.
## Examples
- [Core-only example](examples/core/main.go)
- [Core-only example](examples/core/README.md)
- [ConfigKit example](integrations/ckconfig/examples/simple/README.md)
- [Composed ConfigKit and PromptKit example](integrations/ckprompt/examples/composed/README.md)

42
app.go
View File

@@ -3,38 +3,58 @@ package commandkit
import (
"fmt"
"gitea.maximumdirect.net/eric/CommandKit/internal/alias"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
)
// App owns one command registry and its application identity. Each App has an
// independent registry; command declarations may be reused across Apps.
type App struct {
identity Identity
commands map[string]*commandNode
snapshot *routingSnapshot
identity Identity
commands map[string]*commandNode
routingRules []RoutingRule
aliases *alias.Resolver
flagAliases map[string]resolvedFlagAliases
discoveryFlags map[string]resolvedFlagAliases
snapshot *routingSnapshot
}
type routingSnapshot struct {
metadata *commandmeta.Command
runners map[commandmeta.Key]commandPayload
metadata *commandmeta.Command
runners map[commandmeta.Key]commandPayload
aliases *alias.Resolver
canonical map[string]*canonicalNode
commandAliases []commandmeta.CommandAlias
flagAliases map[string]resolvedFlagAliases
discoveryFlags map[string]resolvedFlagAliases
}
type commandNode struct {
command Command
}
// New creates an application with the supplied identity and initial commands.
// New creates an application with the supplied identity and initial canonical
// commands and routing rules. Use []Declaration to expand a mixed declaration
// collection; []Command cannot be expanded as ...Declaration without
// converting its elements.
//
// The identity name must not be empty. New installs its generated help and
// version commands and caller declarations as one batch; an invalid declaration
// returns an error without exposing an App. The root help and version names are
// reserved for generated commands.
func New(identity Identity, commands ...Command) (*App, error) {
// version commands and caller declarations as one batch; an invalid, nil, or
// zero declaration returns an error without exposing an App. Factory issues
// retained in Command and RoutingRule values are reported during installation.
// Identical duplicate routing mappings are accepted; conflicting mappings
// fail the batch. The root help and version names are reserved for generated
// commands.
func New(identity Identity, declarations ...Declaration) (*App, error) {
if identity.Name == "" {
return nil, fmt.Errorf("application identity name must not be empty")
}
app := &App{identity: identity, commands: make(map[string]*commandNode)}
all := append(app.builtinCommands(), commands...)
builtins := app.builtinCommands()
all := make([]Declaration, 0, len(builtins)+len(declarations))
all = append(all, builtins...)
all = append(all, declarations...)
if err := app.installBatch(all...); err != nil {
return nil, err
}

View File

@@ -1,6 +1,7 @@
package commandkit
import (
"errors"
"fmt"
"strconv"
)
@@ -22,10 +23,10 @@ func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V,
binding.prepare = func(values []string) (preparedValue[I], error) {
value := fallback
supplied := len(values) > 0
for _, raw := range values {
for index, raw := range values {
parsed, err := parse(raw)
if err != nil {
return preparedValue[I]{}, err
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
}
value = parsed
}
@@ -47,10 +48,10 @@ func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[boo
binding.prepare = func(values []string) (preparedValue[I], error) {
value := defaultValue
supplied := len(values) > 0
for _, raw := range values {
for index, raw := range values {
parsed, err := strconv.ParseBool(raw)
if err != nil {
return preparedValue[I]{}, err
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
}
value = parsed
}
@@ -76,10 +77,10 @@ func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, er
supplied := len(values) > 0
if supplied {
parsedValues = make([]V, 0, len(values))
for _, raw := range values {
for index, raw := range values {
parsed, err := parse(raw)
if err != nil {
return preparedValue[I]{}, err
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
}
parsedValues = append(parsedValues, parsed)
}
@@ -158,6 +159,28 @@ type preparedInput[I any] struct {
values []preparedValue[I]
}
type flagOccurrenceError struct {
index int
cause error
}
func (err *flagOccurrenceError) Error() string { return err.cause.Error() }
func (err *flagOccurrenceError) Unwrap() error { return err.cause }
type flagConversionError struct {
binding string
spelling string
occurrence int
cause error
}
func (err *flagConversionError) Error() string {
return fmt.Sprintf("flag %s occurrence %d: %v", err.spelling, err.occurrence, err.cause)
}
func (err *flagConversionError) Unwrap() error { return err.cause }
func (p preparedInput[I]) build() I {
var input I
if p.newInput != nil {
@@ -173,15 +196,36 @@ func (p preparedInput[I]) build() I {
// creates an input value. When enforceRequired is false, missing required flags
// and operands are allowed so metadata help can validate supplied syntax.
func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
return prepareInputWithSpellings(spec, flagValues, nil, operands, enforceRequired)
}
func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
if len(flagValues) != len(spec.Flags) {
return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags))
}
if flagSpellings == nil {
flagSpellings = make([][]string, len(spec.Flags))
}
if len(flagSpellings) != len(spec.Flags) {
return preparedInput[I]{}, fmt.Errorf("received %d flag spelling sets for %d bindings", len(flagSpellings), len(spec.Flags))
}
preparedFlags := make([]preparedValue[I], len(spec.Flags))
missingRequired := ""
for i, flag := range spec.Flags {
binding := flag.binding
value, err := binding.prepare(flagValues[i])
if err != nil {
var occurrenceErr *flagOccurrenceError
if errors.As(err, &occurrenceErr) {
spelling := "--" + binding.meta.Name
if occurrenceErr.index >= 0 && occurrenceErr.index < len(flagSpellings[i]) && flagSpellings[i][occurrenceErr.index] != "" {
spelling = flagSpellings[i][occurrenceErr.index]
}
return preparedInput[I]{}, &flagConversionError{
binding: binding.meta.Name, spelling: spelling,
occurrence: occurrenceErr.index + 1, cause: occurrenceErr.cause,
}
}
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
}
preparedFlags[i] = value

View File

@@ -1,7 +1,9 @@
package commandkit
import (
"context"
"errors"
"io"
"reflect"
"strconv"
"testing"
@@ -245,3 +247,90 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
t.Fatalf("preparation invoked NewInput %d times", factoryCalls)
}
}
func TestExecuteBindsMixedFlagSpellingsWithFreshPresence(t *testing.T) {
type input struct {
count Supplied[int]
enabled Supplied[bool]
message Supplied[string]
tags Supplied[[]string]
required Supplied[string]
instance int
}
type observed struct {
count Supplied[int]
enabled Supplied[bool]
message Supplied[string]
tags Supplied[[]string]
required Supplied[string]
instance int
}
defaultCount := 9
defaultMessage := "fallback"
defaultTags := []string{"base"}
countParses, inputFactories := 0, 0
var calls []observed
command := NewCommand(CommandSpec[input]{
Name: "run",
NewInput: func() input {
inputFactories++
return input{instance: inputFactories}
},
Flags: []Flag[input]{
ValueFlag(FlagMeta{Name: "count"}, &defaultCount, func(value string) (int, error) {
countParses++
return strconv.Atoi(value)
}, func(target *input, value Supplied[int]) { target.count = value }),
BoolFlag(FlagMeta{Name: "enabled"}, true, func(target *input, value Supplied[bool]) { target.enabled = value }),
ValueFlag(FlagMeta{Name: "message"}, &defaultMessage, func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[string]) { target.message = value }),
RepeatFlag(FlagMeta{Name: "tag"}, defaultTags, func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[[]string]) { target.tags = value }),
ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[string]) { target.required = value }),
},
Run: func(_ Invocation, value input) Outcome {
value.tags.Value = append([]string(nil), value.tags.Value...)
calls = append(calls, observed(value))
return Success()
},
})
app, err := New(Identity{Name: "Example"}, command,
Alias("shortcut", "run"),
FlagAliasFor("run", "--number", "--count"),
FlagAliasFor("run", "--active", "--enabled"),
FlagAliasFor("run", "--text", "--message"),
FlagAliasFor("run", "--label", "--tag"),
)
if err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"shortcut", "--number=0", "--count=5", "--active=false", "--enabled=true", "--text=", "--message=last", "--label=a", "--tag=b", "--required=first"},
{"run", "--number=0", "--active=false", "--text=", "--label=c", "--required=second"},
} {
if outcome := app.Execute(context.Background(), args, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
t.Fatalf("Execute(%q) = %#v, want success", args, outcome)
}
}
if countParses != 3 {
t.Fatalf("count conversions = %d, want every one of three occurrences", countParses)
}
if len(calls) != 2 || calls[0].count != (Supplied[int]{Value: 5, Set: true}) || calls[0].enabled != (Supplied[bool]{Value: true, Set: true}) || calls[0].message != (Supplied[string]{Value: "last", Set: true}) || !reflect.DeepEqual(calls[0].tags, Supplied[[]string]{Value: []string{"a", "b"}, Set: true}) || calls[0].required != (Supplied[string]{Value: "first", Set: true}) {
t.Fatalf("first mixed-spelling invocation = %#v", calls)
}
if calls[1].count != (Supplied[int]{Value: 0, Set: true}) || calls[1].enabled != (Supplied[bool]{Value: false, Set: true}) || calls[1].message != (Supplied[string]{Value: "", Set: true}) || !reflect.DeepEqual(calls[1].tags, Supplied[[]string]{Value: []string{"c"}, Set: true}) || calls[1].required != (Supplied[string]{Value: "second", Set: true}) || calls[0].instance == calls[1].instance {
t.Fatalf("second mixed-spelling invocation = %#v", calls[1])
}
if outcome := app.Execute(context.Background(), []string{"shortcut", "--count=7"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeUsage {
t.Fatalf("missing required flag outcome = %#v, want usage", outcome)
}
if inputFactories != 2 || len(calls) != 2 {
t.Fatalf("missing required flag created or ran input: factories=%d calls=%d", inputFactories, len(calls))
}
if outcome := app.Execute(context.Background(), []string{"shortcut", "--required=defaults"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
t.Fatalf("defaulted invocation = %#v, want success", outcome)
}
if len(calls) != 3 || calls[2].count != (Supplied[int]{Value: 9}) || calls[2].enabled != (Supplied[bool]{Value: true}) || calls[2].message != (Supplied[string]{Value: "fallback"}) || !reflect.DeepEqual(calls[2].tags, Supplied[[]string]{Value: []string{"base"}}) || calls[2].required != (Supplied[string]{Value: "defaults", Set: true}) || calls[2].instance == calls[1].instance {
t.Fatalf("omitted values reused prior invocation state: %#v", calls[2])
}
}

View File

@@ -1,8 +1,6 @@
package commandkit
import (
"bytes"
"errors"
"fmt"
"io"
"runtime/debug"
@@ -29,7 +27,7 @@ type versionDocument struct {
Revision string `yaml:"revision"`
}
func (a *App) builtinCommands() []Command {
func (a *App) builtinCommands() []Declaration {
help := captureCommand(CommandSpec[helpInput]{
Name: "help",
Summary: "Show help for commands.",
@@ -58,10 +56,10 @@ func (a *App) builtinCommands() []Command {
if identity.Version == "" {
identity.Version = "dev"
}
return renderVersionOutcome(identity, input.Format, invocation.Streams)
return renderVersionOutcome(identity, input.Format, invocation.Reporter())
},
})
return []Command{help, version}
return []Declaration{help, version}
}
func parseVersionScope(value string) (string, error) {
@@ -78,61 +76,41 @@ func parseVersionFormat(value string) (string, error) {
return value, nil
}
func (a *App) renderHelpOutcome(path []string, streams Streams) Outcome {
session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{})
return a.renderHelpOutcomeWithSession(path, streams, session)
}
func (a *App) renderHelpTargets(targets []string, streams Streams) Outcome {
session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{})
return a.renderHelpTargetsWithSession(targets, streams, session)
}
func (a *App) renderHelpTargetsWithSession(targets []string, streams Streams, session *internalcobra.Session) Outcome {
return a.renderHelpOutcomeWithSession(targets, streams, session)
}
func (a *App) renderHelpOutcomeWithSession(path []string, streams Streams, session *internalcobra.Session) Outcome {
content, failure := session.RenderHelp(path)
func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, reporter *Reporter, session *internalcobra.Session) Outcome {
content, failure := session.RenderHelpView(view)
if failure != nil {
if failure.Usage {
return Usage(errors.New(failure.Message))
return Usage(failure.Message)
}
return Failure(errors.New(failure.Message))
}
if err := writeReport(streams.Out, []byte(content)); err != nil {
return incompleteReport(err)
return Failure(failure.Message)
}
_ = reporter.RenderOutput(func(w io.Writer) error {
_, err := io.WriteString(w, content)
return err
})
return Success()
}
func renderVersionOutcome(identity Identity, format string, streams Streams) Outcome {
var content []byte
func renderVersionOutcome(identity Identity, format string, reporter *Reporter) Outcome {
if identity.Version == "" {
identity.Version = "dev"
}
switch format {
case "text":
name := identity.Name
if identity.Version == "" {
identity.Version = "dev"
}
line := name + " " + identity.Version
line := identity.Name + " " + identity.Version
if identity.Revision != "" {
line += " (" + identity.Revision + ")"
}
content = []byte(line + "\n")
_ = reporter.RenderOutput(func(w io.Writer) error {
_, err := io.WriteString(w, line+"\n")
return err
})
case "yaml":
if identity.Version == "" {
identity.Version = "dev"
}
var buffer bytes.Buffer
if err := encodeYAML(&buffer, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision}); err != nil {
return Failure(err)
}
content = buffer.Bytes()
_ = reporter.RenderOutput(func(w io.Writer) error {
return encodeYAML(w, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision})
})
default:
return Usage(fmt.Errorf("unsupported version format %q", format))
}
if err := writeReport(streams.Out, content); err != nil {
return incompleteReport(err)
return Usage(fmt.Sprintf("unsupported version format %q", format))
}
return Success()
}
@@ -143,22 +121,6 @@ func encodeYAML(writer io.Writer, document any) error {
return encoder.Encode(document)
}
func writeReport(writer io.Writer, content []byte) error {
checked := &checkedWriter{writer: writer}
_, err := checked.Write(content)
if err == nil {
err = checked.failure
}
return err
}
func incompleteReport(err error) Outcome {
outcome := Failure(err)
outcome.ReportIncomplete = true
outcome.DeliveryError = err
return outcome
}
func readBuildInfo() *debug.BuildInfo {
info, ok := debug.ReadBuildInfo()
if !ok {

110
builtins_reporting_test.go Normal file
View File

@@ -0,0 +1,110 @@
package commandkit
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
)
func builtinReportingTestApp(t *testing.T) *App {
t.Helper()
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example", Version: "1.2.3"}, command,
Group("promptkit", "PromptKit", Group("list", "List", simpleRunnable("collections"))),
Alias("r", "run"), Alias("list collections", "promptkit list collections"),
)
if err != nil {
t.Fatal(err)
}
return app
}
func TestHelpAndVersionUseManagedOutput(t *testing.T) {
app := builtinReportingTestApp(t)
for _, args := range [][]string{{"help", "--quiet"}, {"version", "--quiet"}, {"list", "--help", "--quiet"}} {
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || outcome.Status != 0 {
t.Errorf("Execute(%q) = %#v", args, outcome)
}
if output.Len() != 0 || diagnostics.Len() != 0 {
t.Errorf("Execute(%q) wrote output %q or diagnostics %q", args, output.String(), diagnostics.String())
}
if outcome.Presentation.Output.State != DeliverySuppressed {
t.Errorf("Execute(%q) output presentation = %#v", args, outcome.Presentation.Output)
}
}
for _, test := range []struct {
args []string
want string
}{
{args: []string{"help", "run"}, want: "run"},
{args: []string{"help", "r"}, want: "Alias for: Example run"},
{args: []string{"list", "--help"}, want: "Reporting Flags"},
{args: []string{"version"}, want: "Example 1.2.3"},
{args: []string{"version", "--format=yaml"}, want: "version: 1.2.3"},
} {
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), test.args, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || !strings.Contains(output.String(), test.want) {
t.Errorf("Execute(%q) = %#v, output %q", test.args, outcome, output.String())
}
}
}
func TestHelpAndVersionDestinationFailuresUseReporterObservations(t *testing.T) {
app := builtinReportingTestApp(t)
writeErr := errors.New("help/version destination failed")
for _, args := range [][]string{{"help", "run"}, {"version"}} {
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, Streams{
Out: executeReportingFailureWriter{err: writeErr}, Err: &diagnostics,
})
if outcome.Kind != OutcomeOperational || outcome.Status != 1 {
t.Errorf("Execute(%q) = %#v", args, outcome)
}
if !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != DeliveryIncomplete {
t.Errorf("Execute(%q) presentation = %#v", args, outcome.Presentation)
}
if !strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output") {
t.Errorf("Execute(%q) diagnostics = %q", args, diagnostics.String())
}
}
}
func TestInvalidHelpAndVersionRequestsRemainUsageOutcomes(t *testing.T) {
app := builtinReportingTestApp(t)
for _, args := range [][]string{{"help", "unknown"}, {"version", "--scope=unknown"}, {"version", "--format=unknown"}} {
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeUsage || outcome.Status != 2 {
t.Errorf("Execute(%q) = %#v", args, outcome)
}
if !strings.Contains(diagnostics.String(), "Usage: Example") {
t.Errorf("Execute(%q) diagnostics = %q", args, diagnostics.String())
}
}
}
func TestQuietBuiltinRenderingSuppressesDestinationWrites(t *testing.T) {
app := builtinReportingTestApp(t)
// A quiet successful run must not touch its supplied result destination.
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"version", "--quiet"}, Streams{
Out: executeReportingFailureWriter{err: io.ErrClosedPipe}, Err: &diagnostics,
})
if outcome.Kind != OutcomeSuccess || outcome.Presentation.Output.State != DeliverySuppressed {
t.Fatalf("quiet version outcome = %#v", outcome)
}
if diagnostics.Len() != 0 {
t.Fatalf("quiet version diagnostics = %q", diagnostics.String())
}
}

View File

@@ -7,14 +7,65 @@ import (
"unicode"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
// Command is an opaque, reusable declaration captured by NewCommand, Group,
// RootGroup, or an integration. Capture copies declaration slices but retains
// callback and caller-owned object references. Installation validates the
// declaration before changing an App registry.
// Command is an opaque, reusable declaration of canonical commands and groups.
// NewCommand, Group, RootGroup, and integration command factories return
// Command values. Capture copies declaration slices but retains callback and
// caller-owned object references. Installation validates the declaration
// before changing an App registry.
type Command struct{ record *commandRecord }
// Declaration is a captured canonical command or application routing rule
// accepted by New and App.Register. Use []Declaration when collecting both
// kinds. Group and RootGroup children remain Commands; install routing rules
// alongside the completed command tree through New or Register. Only
// CommandKit declaration types implement Declaration.
//
// A zero Command, zero RoutingRule, nil declaration, or typed nil declaration
// pointer is invalid. Use NoCommand to represent an intentionally absent
// optional command.
type Declaration interface {
commandkitDeclaration()
}
func (Command) commandkitDeclaration() {}
// RoutingRule is an opaque, reusable command or flag alias declaration. Alias,
// FlagAlias, and FlagAliasFor capture routing rules eagerly and retain any
// construction issues for New or App.Register to report. Identical duplicate
// mappings are accepted; competing targets for the same source are errors.
type RoutingRule struct{ record *routingRuleRecord }
func (RoutingRule) commandkitDeclaration() {}
type routingRuleKind uint8
const (
commandAliasRule routingRuleKind = iota + 1
globalFlagAliasRule
scopedFlagAliasRule
)
type capturedFlagSpelling struct {
name string
short bool
}
type routingRuleRecord struct {
kind routingRuleKind
sourcePath commandpath.Path
targetPath commandpath.Path
scopePath commandpath.Path
sourceFlag capturedFlagSpelling
targetFlag capturedFlagSpelling
sourceText string
targetText string
scopeText string
issues []error
}
type commandRecord struct {
name string
summary string
@@ -130,7 +181,7 @@ type commandPayload interface {
hasRun() bool
bridgeFlags() []commandmeta.Flag
bridgeArguments() []commandmeta.Argument
prepare([][]string, []string, bool) (func(*execution) Outcome, error)
prepare([][]string, [][]string, []string, bool) (func(*execution) Outcome, error)
}
type typedCommand[I any] struct {
@@ -177,8 +228,8 @@ func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument {
return arguments
}
func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired)
func (c typedCommand[I]) prepare(flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
prepared, err := prepareInputWithSpellings(c.spec, flagValues, flagSpellings, operands, enforceRequired)
if err != nil {
return nil, err
}
@@ -192,13 +243,15 @@ func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enfor
if c.privateRun != nil {
return c.privateRun(current.commandContext(), input)
}
return c.spec.Run(Invocation{Context: current.ctx, Streams: current.streams}, input)
return c.spec.Run(current.invocation(), input)
}, nil
}
// NewCommand captures a typed command declaration without constructing its
// input or running callbacks. Construction issues are retained and reported
// when the declaration is installed. The returned declaration can be reused.
// NewCommand captures a typed canonical command declaration without
// constructing its input or running callbacks. It copies the spec's flags,
// arguments, and children slices. Construction issues are retained and
// reported when the declaration is installed; NewCommand itself does not
// return an error. The returned declaration can be reused across Apps.
func NewCommand[I any](spec CommandSpec[I]) Command {
return captureCommand(spec, nil)
}
@@ -220,25 +273,29 @@ func captureCommand[I any](spec CommandSpec[I], privateRun func(commandExecution
return Command{record: record}
}
// Group captures a non-runnable command group and a copy of its relative child
// slice. Child declarations remain reusable and are not recursively copied.
// Group captures a non-runnable canonical command group and a copy of its
// relative Command child slice. Child declarations remain reusable and are not
// recursively copied. Routing rules cannot be children; install them alongside
// the root command tree through New or App.Register.
func Group(name, summary string, children ...Command) Command {
record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)}
captureComposition(record)
return Command{record: record}
}
// RootGroup captures a group that may only be installed at the application root.
// Installation rejects it below any other command, including through wrappers.
// RootGroup captures a group that may only be installed at the application
// root. Installation rejects it below any other command, including through
// wrappers. Its children are canonical Commands; install routing rules at the
// application root separately.
func RootGroup(name, summary string, children ...Command) Command {
command := Group(name, summary, children...)
command.record.rootOnly = true
return command
}
// WithError returns a new declaration that also retains a non-nil err for
// installation. Earlier declaration and child errors remain available, and
// wrapped error identity is preserved. A nil err returns command unchanged.
// WithError returns a new Command declaration that also retains a non-nil err
// for installation. Earlier declaration and child errors remain available,
// and wrapped error identity is preserved. A nil err returns command unchanged.
func WithError(command Command, err error) Command {
if err == nil {
return command
@@ -254,11 +311,91 @@ func WithError(command Command, err error) Command {
return Command{record: &record}
}
// NoCommand explicitly represents an absent optional declaration. Installation
// skips it, but reports errors attached to it; a zero Command is invalid and is
// not an absence marker.
// NoCommand explicitly represents an absent optional command declaration.
// Installation skips it, but reports errors attached to it; a zero Command is
// invalid and is not an absence marker. Routing rules have no absence marker.
func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
// Alias captures a command-path alias from path to target. Paths are
// whitespace-separated sequences of command names relative to the application
// root. The target must resolve to a canonical command or group; aliases do not
// add operands or transform flag values. Construction issues are retained for
// New or App.Register to report. Identical mappings are accepted, while
// different targets for the same source path are installation errors.
func Alias(path, target string) RoutingRule {
record := &routingRuleRecord{
kind: commandAliasRule,
sourceText: path,
targetText: target,
}
record.sourcePath = captureRoutingPath("source", path, &record.issues)
record.targetPath = captureRoutingPath("target", target, &record.issues)
return RoutingRule{record: record}
}
// FlagAlias captures an application-wide flag spelling alias. Spellings must
// be one long flag name or one individual short spelling. The mapping applies
// wherever its canonical target flag is available and contributes nothing on
// commands without that flag. Identical mappings are accepted; different
// targets for the same source spelling in this scope are installation errors.
func FlagAlias(spelling, target string) RoutingRule {
record := &routingRuleRecord{
kind: globalFlagAliasRule,
sourceText: spelling,
targetText: target,
}
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
return RoutingRule{record: record}
}
// FlagAliasFor captures a flag spelling alias scoped to a canonical command
// path, not an alias path. The command and canonical target flag must be
// available in the complete installation. Scoped mappings take precedence over
// application-wide mappings with the same source spelling. Identical mappings
// are accepted; competing targets in the same scope are installation errors.
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
record := &routingRuleRecord{
kind: scopedFlagAliasRule,
sourceText: spelling,
targetText: target,
scopeText: commandPath,
}
record.scopePath = captureRoutingPath("command scope", commandPath, &record.issues)
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
return RoutingRule{record: record}
}
func captureRoutingPath(label, value string, issues *[]error) commandpath.Path {
path, err := commandpath.ParseDeclaration(value)
if err != nil {
*issues = append(*issues, fmt.Errorf("invalid %s path: %w", label, err))
}
return path
}
func captureFlagSpelling(label, value string, issues *[]error) capturedFlagSpelling {
spelling, err := parseFlagSpelling(value)
if err != nil {
*issues = append(*issues, fmt.Errorf("invalid %s flag spelling %q: %w", label, value, err))
}
return spelling
}
func parseFlagSpelling(value string) (capturedFlagSpelling, error) {
if strings.HasPrefix(value, "--") {
name := value[2:]
if validFlagName(name) {
return capturedFlagSpelling{name: name}, nil
}
}
if len(value) == 2 && value[0] == '-' && value[1] != '-' {
return capturedFlagSpelling{name: value[1:], short: true}, nil
}
return capturedFlagSpelling{}, fmt.Errorf("must be a long flag or one-byte short flag spelling")
}
func captureComposition(record *commandRecord) {
if err := validateCommandName(record.name); err != nil {
record.issues = append(record.issues, err)
@@ -297,7 +434,7 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
}
record := command.record
path := append(append([]string(nil), parents...), record.name)
label := strings.Join(path, " ")
label := commandpath.FromTokens(path).String()
for _, issue := range record.issues {
if label != "" {
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
@@ -320,11 +457,7 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
}
func validateCommandName(name string) error {
fields := strings.Fields(name)
if len(fields) != 1 || fields[0] != name || strings.HasPrefix(name, "-") || name == "--" {
return fmt.Errorf("command name %q must be one non-option token", name)
}
return nil
return commandpath.ValidateComponent(name)
}
func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
@@ -338,6 +471,9 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
if binding.meta.Name == "help" {
return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name)
}
if commandmeta.IsReportingFlag(binding.meta.Name) {
return fmt.Errorf("flag name %q is reserved for reporting", binding.meta.Name)
}
if _, exists := flagNames[binding.meta.Name]; exists {
return fmt.Errorf("duplicate flag name %q", binding.meta.Name)
}

118
debug_events_test.go Normal file
View File

@@ -0,0 +1,118 @@
package commandkit
import (
"bytes"
"context"
"errors"
"strings"
"testing"
)
func TestDebugEventsFollowModeAndFinalizationOrder(t *testing.T) {
cause := errors.New("private operation cause")
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(inv Invocation, _ struct{}) Outcome {
if err := inv.Reporter().AtEnd().Info("deferred summary"); err != nil {
t.Errorf("queue summary: %v", err)
}
return Failure("operation failed").WithCause(cause)
},
})
app, err := New(Identity{Name: "Example"}, command, Alias("shortcut", "run"), FlagAlias("--trace", "--debug"))
if err != nil {
t.Fatal(err)
}
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"shortcut", "--verbose", "--trace"}, Streams{Err: &diagnostics})
if outcome.Kind != OutcomeOperational || !errors.Is(outcome.Cause, cause) {
t.Fatalf("outcome = %#v", outcome)
}
got := diagnostics.String()
for _, fragment := range []string{
"resolved command alias \"shortcut\" to \"run\"",
"selected debug reporting mode using --trace (alias for --debug)",
"deferred summary",
"handler completed for \"run\" with operational outcome and status 1",
"ERROR: operation failed",
} {
if !strings.Contains(got, fragment) {
t.Errorf("diagnostics %q omit %q", got, fragment)
}
}
if strings.Contains(got, cause.Error()) {
t.Fatalf("diagnostics exposed the operation cause: %q", got)
}
ordered := []string{
"resolved command alias",
"selected debug reporting mode",
"deferred summary",
"handler completed",
"ERROR: operation failed",
}
previous := -1
for _, fragment := range ordered {
index := strings.Index(got, fragment)
if index <= previous {
t.Fatalf("diagnostic event %q out of order in %q", fragment, got)
}
previous = index
}
}
func TestDebugEventsRespectFinalQuietSelection(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run", "--debug", "--quiet"}, Streams{Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || diagnostics.Len() != 0 {
t.Fatalf("outcome = %#v, diagnostics = %q", outcome, diagnostics.String())
}
}
func TestDebugCompletionRequiresAHandler(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
var diagnostics bytes.Buffer
app.Execute(context.Background(), []string{"unknown", "--debug"}, Streams{Err: &diagnostics})
if strings.Contains(diagnostics.String(), "handler completed") {
t.Fatalf("no-handler diagnostics include completion: %q", diagnostics.String())
}
}
func TestDebugDeliveryFailureAffectsInvocationOutcome(t *testing.T) {
ran := false
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome {
ran = true
return Success()
},
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
writeErr := errors.New("debug destination failed")
outcome := app.Execute(context.Background(), []string{"run", "--debug"}, Streams{
Err: executeReportingFailureWriter{err: writeErr},
})
if !ran {
t.Fatal("debug delivery failure prevented handler execution")
}
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeErr) {
t.Fatalf("outcome = %#v", outcome)
}
}

View File

@@ -0,0 +1,241 @@
package commandkit_test
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
func TestPublicInstallationAcceptsMixedDeclarations(t *testing.T) {
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "show",
Summary: "Show a value.",
Flags: []commandkit.Flag[struct{}]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, commandkit.Supplied[string]) {}),
},
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() },
})
declarations := []commandkit.Declaration{
command,
commandkit.Alias("inspect", "show"),
commandkit.FlagAlias("--fmt", "--format"),
commandkit.FlagAliasFor("show", "-f", "--format"),
}
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
if err != nil {
t.Fatal(err)
}
if app == nil {
t.Fatal("New() returned a nil app")
}
}
func TestPublicAliasesPreserveCommandsAcrossDeclarationOrders(t *testing.T) {
for _, test := range []struct {
name string
rulesFirst bool
}{
{name: "commands first"},
{name: "routing rules first", rulesFirst: true},
} {
t.Run(test.name, func(t *testing.T) {
type runInput struct{ words commandkit.Supplied[[]string] }
type inspectInput struct{ id commandkit.Supplied[string] }
type showInput struct {
profile commandkit.Supplied[string]
format commandkit.Supplied[string]
}
var runParses, inspectParses, optionalParses int
var runValues [][]string
var inspectValues []string
var showValues []string
run := commandkit.NewCommand(commandkit.CommandSpec[runInput]{
Name: "run",
Arguments: []commandkit.Argument[runInput]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "words"}, 0, -1,
func(value string) (string, error) { runParses++; return value, nil },
func(target *runInput, value commandkit.Supplied[[]string]) { target.words = value },
)},
Run: func(_ commandkit.Invocation, value runInput) commandkit.Outcome {
runValues = append(runValues, append([]string(nil), value.words.Value...))
return commandkit.Success()
},
})
inspect := commandkit.NewCommand(commandkit.CommandSpec[inspectInput]{
Name: "inspect",
Arguments: []commandkit.Argument[inspectInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id"}, true,
func(value string) (string, error) { inspectParses++; return value, nil },
func(target *inspectInput, value commandkit.Supplied[string]) { target.id = value },
)},
Run: func(_ commandkit.Invocation, value inspectInput) commandkit.Outcome {
inspectValues = append(inspectValues, value.id.Value)
return commandkit.Success()
},
})
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
Name: "show",
Arguments: []commandkit.Argument[showInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "profile"}, false,
func(value string) (string, error) { optionalParses++; return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.profile = value },
)},
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
)},
Run: func(_ commandkit.Invocation, value showInput) commandkit.Outcome {
showValues = append(showValues, value.profile.Value+":"+value.format.Value)
return commandkit.Success()
},
})
commands := []commandkit.Declaration{run, inspect, show}
rules := []commandkit.Declaration{
commandkit.Alias("r", "run"),
commandkit.Alias("r full", "inspect"),
commandkit.Alias("inspect-shortcut", "inspect"),
commandkit.Alias("i", "inspect-shortcut"),
commandkit.FlagAlias("--fmt", "--format"),
}
declarations := append([]commandkit.Declaration(nil), commands...)
if test.rulesFirst {
declarations = append(append([]commandkit.Declaration(nil), rules...), commands...)
} else {
declarations = append(declarations, rules...)
}
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
if err != nil {
t.Fatal(err)
}
if runParses != 0 || inspectParses != 0 || optionalParses != 0 {
t.Fatalf("installation invoked operand converters: %d/%d/%d", runParses, inspectParses, optionalParses)
}
for _, args := range [][]string{{"run", "full"}, {"r", "full", "item-1"}, {"i", "item-2"}, {"show", "profile", "--fmt=json"}} {
if outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("Execute(%q) = %#v, want success", args, outcome)
}
}
if !reflect.DeepEqual(runValues, [][]string{{"full"}}) {
t.Fatalf("canonical run operands = %#v, want [[full]]", runValues)
}
if !reflect.DeepEqual(inspectValues, []string{"item-1", "item-2"}) {
t.Fatalf("routed required operands = %#v", inspectValues)
}
if !reflect.DeepEqual(showValues, []string{"profile:json"}) || optionalParses != 1 {
t.Fatalf("optional operand/flag values = %#v, converter calls %d", showValues, optionalParses)
}
parsesBeforeReuse := [3]int{runParses, inspectParses, optionalParses}
secondApp, err := commandkit.New(commandkit.Identity{Name: "Second"}, declarations...)
if err != nil {
t.Fatalf("reusing declarations: %v", err)
}
if got := [3]int{runParses, inspectParses, optionalParses}; got != parsesBeforeReuse {
t.Fatalf("reinstalling declarations invoked operand converters: %v -> %v", parsesBeforeReuse, got)
}
if outcome := secondApp.Execute(context.Background(), []string{"run", "again"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("reused declaration execution = %#v", outcome)
}
if !reflect.DeepEqual(runValues, [][]string{{"full"}, {"again"}}) {
t.Fatalf("reused command declaration values = %#v", runValues)
}
})
}
}
func TestPublicRootGroupRegistrationAndFailedBatchRecovery(t *testing.T) {
type showInput struct{ format commandkit.Supplied[string] }
var formatParses, lateRuns int
var formats []string
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
Name: "show",
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil),
func(value string) (string, error) { formatParses++; return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
)},
Run: func(_ commandkit.Invocation, value showInput) commandkit.Outcome {
formats = append(formats, value.format.Value)
return commandkit.Success()
},
})
family := commandkit.RootGroup("config", "Configuration", show)
constructionFailure := errors.New("family construction failed")
if _, err := commandkit.New(commandkit.Identity{Name: "Broken"}, commandkit.WithError(family, fmt.Errorf("adapter: %w", constructionFailure))); !errors.Is(err, constructionFailure) {
t.Fatalf("New() error = %v, want retained family construction failure", err)
}
app, err := commandkit.New(commandkit.Identity{Name: "Example"})
if err != nil {
t.Fatal(err)
}
var before bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &before, Err: io.Discard})
if strings.Contains(before.String(), "config") {
t.Fatalf("root help showed an unregistered family: %q", before.String())
}
if err := app.Register(family,
commandkit.Alias("cfg-show", "config show"),
commandkit.Alias("cs", "cfg-show"),
commandkit.FlagAlias("--fmt", "--format"),
); err != nil {
t.Fatal(err)
}
if formatParses != 0 {
t.Fatalf("registration invoked flag converter %d times", formatParses)
}
var after bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &after, Err: io.Discard})
for _, fragment := range []string{"config", "Aliases:", "cfg-show", "cs"} {
if !strings.Contains(after.String(), fragment) {
t.Errorf("updated root help %q does not contain %q", after.String(), fragment)
}
}
if outcome := app.Execute(context.Background(), []string{"cs", "--fmt=yaml"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("registered alias execution = %#v", outcome)
}
if !reflect.DeepEqual(formats, []string{"yaml"}) || formatParses != 1 {
t.Fatalf("registered family values = %#v, converter calls %d", formats, formatParses)
}
if err := app.Register(commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "cfg-show", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})); err == nil {
t.Fatal("registration accepted a canonical command that conflicts with an existing alias")
}
var afterConflict bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &afterConflict, Err: io.Discard})
if !strings.Contains(afterConflict.String(), "cfg-show") {
t.Fatalf("failed relationship validation changed registered aliases: %q", afterConflict.String())
}
late := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "late", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
lateRuns++
return commandkit.Success()
}})
registrationFailure := errors.New("retained registration failure")
err = app.Register(commandkit.WithError(late, fmt.Errorf("family: %w", registrationFailure)))
if !errors.Is(err, registrationFailure) {
t.Fatalf("Register() error = %v, want retained failure", err)
}
if err := app.Register(late, commandkit.Alias("late-shortcut", "missing")); err == nil {
t.Fatal("registration accepted a batch whose alias target is absent")
}
var afterFailure bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &afterFailure, Err: io.Discard})
if strings.Contains(afterFailure.String(), "late-shortcut") || strings.Contains(afterFailure.String(), " late ") {
t.Fatalf("failed batch changed root help: %q", afterFailure.String())
}
if err := app.Register(late, commandkit.Alias("late-shortcut", "late")); err != nil {
t.Fatalf("registration after failed batch: %v", err)
}
var recovered bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &recovered, Err: io.Discard})
if !strings.Contains(recovered.String(), "late-shortcut") {
t.Fatalf("recovered root help omitted alias: %q", recovered.String())
}
if outcome := app.Execute(context.Background(), []string{"late-shortcut"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess || lateRuns != 1 {
t.Fatalf("recovered alias execution = %#v, runs %d", outcome, lateRuns)
}
}

7
doc.go
View File

@@ -1,5 +1,8 @@
// Package commandkit provides the core instance-owned CLI dispatcher, typed
// command declarations, input bindings, and invocation outcomes. Optional
// integration modules provide ConfigKit and PromptKit-specific declarations;
// command declarations, application-owned command and flag aliases, input
// bindings, invocation-scoped reporting, and outcomes with separate operation
// causes and presentation observations. New and App.Register accept canonical
// Command declarations and RoutingRule declarations together. Optional
// integration modules provide ConfigKit and PromptKit-specific commands;
// importing those modules does not install their commands.
package commandkit

View File

@@ -0,0 +1,68 @@
# ADR 0013: Separate application alias routing from canonical command ownership
## Status
Accepted. Refines [ADR 0011](0011-captured-declarations-and-installation.md)
by extending its shared installation boundary to application routing rules.
Its canonical command ownership and declaration principles remain in force.
## Date
2026-10-19
## Context
Applications need alternate command paths and flag spellings that suit their
operator vocabulary. These conveniences should reuse the behavior supplied by
application commands and optional integrations. Canonical command families
already establish ownership of handlers, namespaces, inputs, and invocation
behavior.
A routing layer allows applications to choose alternate spellings while
preserving those ownership boundaries. Commands and routing rules have distinct
roles, and their relationships require validation during application assembly.
## Decision
CommandKit core owns alias declaration, resolution, and coordination with parsing
and help. Applications choose their alias mappings. Alias infrastructure is
available independently of optional integrations under the module boundaries
established by [ADR 0010](0010-optional-integration-modules.md).
Aliases belong to an application routing layer that directs invocations to
canonical commands and flags. Canonical definitions retain ownership of their
handlers, inputs, and namespaces. An alternate path within an integration's
namespace remains an application routing rule; the integration continues to own
the canonical command family and its operations.
Canonical commands and routing rules are distinct declaration kinds installed
through a common core assembly boundary. Installation validates their
relationships together, preserves canonical definitions and namespace ownership,
and applies the batch-atomicity and retained-error model of ADR 0011 to both
layers. Declaration capture and reuse follow that existing model.
An invocation through an alias uses the canonical target's parsing, typed
bindings, validation, and ordinary invocation lifecycle. Execution behavior
remains with the canonical command and its owning application or integration.
Core coordinates alternate routing and presentation through the same command
infrastructure.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Install aliases as wrapper commands | Reuses the command tree for discovery, but introduces additional command nodes and handler delegation into namespace ownership and invocation lifetimes. |
| Have applications rewrite arguments before dispatch | Gives applications direct control over shortcuts, but repeats routing infrastructure and separates alias handling from target validation, parsing, and help. |
| Register aliases through a separate assembly API | Makes routing declarations explicit, but splits validation of commands and their alternate routes across installation boundaries. Distinct declaration kinds express the separation within one atomic batch. |
## Consequences
Applications can tailor operator vocabulary while reusing canonical behavior.
Core maintains the relationship between routing rules and canonical definitions
as declarations are installed. Optional integrations continue to supply their
command families through the existing ownership model.
Routing, parsing, and help must coordinate across the two layers. Exact API
shapes, resolution rules, and presentation choices belong in GoDoc and the
[CLI reference](../cli.md) when implemented. Those details can evolve within
this architectural separation.

View File

@@ -0,0 +1,79 @@
# ADR 0014: Share command-line presentation within each invocation
## Status
Accepted. Extends [ADR 0005](0005-invocation-outcomes.md) with shared reporting
for each invocation. Its outcome classifications, standard status meanings,
output channels, error-cause preservation, and application ownership of process
exit remain in force.
## Date
2026-10-25
## Context
Applications need consistent control over command results, messages shown during
a run, and messages shown at its end. Sharing that control reduces repeated
output code across applications and integrations.
An application decides what an error means for its work. It may recover silently,
show a warning, or end the invocation. CommandKit needs the chosen outcome and
messages to present that decision to the operator.
## Decision
CommandKit core provides one reporting facility for each invocation. Application
commands, integration commands, and core commands use it for results, immediate
messages, and deferred messages. Core applies shared rules for visibility,
formatting, and delivery, and completes reporting when the invocation ends.
Callers choose the output destinations.
Applications decide how to respond to domain-specific errors and choose their
outcomes and messages. CommandKit may provide shared helpers for carrying,
inspecting, and presenting errors while leaving those decisions with the
application.
Applications supply explicit outcomes and explanations for failures. They may
also supply a Go error cause. CommandKit keeps that error unchanged for the caller
to inspect. Supplying a cause alone does not select an outcome or generate an
operator-facing message. CommandKit remains responsible for handling its own
errors, including routing, parsing, and presentation failures.
Integrations adapt upstream results and diagnostics into command outcomes and
messages. The upstream libraries retain ownership of their meanings and rules
for safe disclosure.
An Error message explains a failed invocation with a non-zero status. Its
explanation is presented at the end of the invocation, after normal reporting
finishes. The returned outcome carries that explanation. The application entry
point decides how to exit the process.
CommandKit reports rendering and delivery failures separately from the command's
explanation. A presentation failure produces an operational failure for standard
outcomes under ADR 0005. Application-defined domain classifications are preserved,
along with an already non-zero domain status. A successful domain outcome receives
a non-zero status if presentation fails. The original explanation and any supplied
cause remain available to the caller. Intentionally suppressed output is not a
delivery failure.
Reporting keeps the state needed to deliver messages and return the invocation
outcome. Logging and message history remain application concerns.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Let each application manage all presentation | Gives applications full control, but repeats visibility and delivery code across commands and integrations. |
| Have core decide whether domain-specific errors require recovery, a warning, or failure | Reduces some caller code, but gives core decisions that depend on application behavior. |
| Use separate facilities for results, immediate messages, and deferred messages | Lets each facility serve one purpose, but spreads visibility and final delivery across several paths. |
## Consequences
Applications and integrations share presentation behavior while choosing their
own results and messages. Core handles reporting failures consistently and keeps
them visible in the returned outcome.
GoDoc and the [CLI reference](../cli.md) define the implemented API, flags,
message formats, and delivery details. Those details can evolve within this
ownership model.

View File

@@ -66,6 +66,93 @@ version: dev
revision: ""
```
## Reporting
CommandKit applies one shared reporting policy to application handlers,
integrations, help, version, and CommandKit's own invocation diagnostics. The
caller passes result and diagnostic writers to `App.Execute`; substantive
results go to `Streams.Out`, and categorized messages, terminal explanations,
and usage guidance go to `Streams.Err`.
### Modes and visibility
Each invocation starts in ordinary mode. `--quiet`, `--verbose`, and `--debug`
are mutually exclusive; the last enabled occurrence selects the mode. They
accept presence or an explicit boolean value. A `false` occurrence clears the
selected mode only when it names the current mode, returning to ordinary mode;
it does not restore an earlier selection. Invalid boolean values are usage
errors, even if a later flag would select another mode.
| Category | `--quiet` | No mode flag | `--verbose` | `--debug` |
| --- | --- | --- | --- | --- |
| Substantive result | Suppressed | Shown | Shown | Shown |
| Terminal failure | Shown | Shown | Shown | Shown |
| Warning | Suppressed | Suppressed | Shown | Shown |
| Info | Suppressed | Suppressed | Shown | Shown |
| Debug | Suppressed | Suppressed | Suppressed | Shown |
| Progress, unless enabled explicitly | Suppressed | Suppressed | Suppressed | Shown |
`--progress` independently enables Progress; `--progress=false` disables it.
Both accept the existing boolean syntax, and the last occurrence wins. An
explicit setting overrides the mode default, except quiet always suppresses
Progress. Without an explicit setting, debug enables Progress; ordinary and
verbose modes leave it disabled. Progress does not enable Info, Warning, or
Debug.
### Placement and aliases
Reporting flags are available on application commands, the root, canonical
groups, help, version, and alias-only discovery help. Place them after the
complete command path; they may be interspersed with operands after that path.
For example:
```text
example export --quiet
example config show --verbose
example promptkit test all --debug --progress=false
example help config show --quiet
example version --quiet
```
Reporting flags do not go before or between command-path components. Tokens
consumed as values and tokens after `--` retain their normal meaning.
The reserved long spellings are `--quiet`, `--verbose`, `--debug`, and
`--progress`; application declarations cannot reuse them. There are no built-in
short spellings. Applications may define aliases such as `-q → --quiet`,
`-v → --verbose`, and `-d → --debug`; canonical and aliased occurrences share
the same ordering. Thus `example export --verbose -q` selects quiet mode.
### Channels and messages
Substantive reports use the result destination. Warnings, Info, Progress,
Debug, terminal explanations, and usage guidance use the diagnostic destination.
Simple Warning, Debug, and terminal failure messages begin with `WARN:`,
`DEBUG:`, and `ERROR:` respectively. Info, Progress, and substantive results
have no prefix. For multiline messages, the prefix appears on the opening line;
native structured details keep their own formatting without duplicate labels.
Progress is streamed as unprefixed lines; terminal redraw and progress bars are
not provided. A failed outcome with no message or detail uses the fallback
`ERROR: Command failed.`.
Quiet mode suppresses successful help and version output, as it does other
substantive results. Usage guidance and routing, parsing, and operational
failures remain visible in every mode. A recovered problem is reported only if
the application chooses to submit a Warning; an error cause by itself does not
create a message.
### Presentation failures
CommandKit continues rendering substantive output under quiet mode through a
discard destination. This suppresses writes to the caller's result writer while
still detecting report preparation, encoding, or rendering failures. An
intentional suppression is not a delivery failure. A failure while rendering or
writing requested content is retained in the returned `Outcome` and makes an
otherwise successful or usage invocation operational with status `1`. A domain
classification and its nonzero status are preserved; a zero-status domain
outcome becomes status `1` when presentation fails. Earlier bytes already
written remain visible.
## Registered command syntax
Options are parsed after the complete command path and may be interspersed with
@@ -83,6 +170,74 @@ Typed inputs keep supplied state separate from defaults. An explicit empty,
false, or zero value is supplied. Help and version are available without
constructing application command inputs.
## Application-owned aliases
Applications can install command and flag aliases alongside their canonical
commands. Aliases route an invocation to existing behavior; they do not add
handlers, commands, or flags to a canonical command family.
Command alias paths are sequences of command tokens relative to the application
root. The executable name, flags, flag values, and operands are not part of an
alias path. Matching uses complete tokens and chooses the longest matching
path. A group alias preserves its remaining command tokens, and aliases can
compose through chains. For example, `list collections` can route to
`promptkit list collections`, while `pk` can route to the `promptkit` group.
The target must resolve to a canonical command or group.
Aliases do not rewrite operands or supply arguments. In particular,
`promptkit test KIND` remains one canonical command whose `KIND` is an operand;
`all` is an operand value, not a child command. `smoke → promptkit test` is a
valid shortcut because it preserves the caller's `KIND` value.
Flag aliases can be application-wide or scoped to a canonical command path.
An application-wide alias is effective wherever its canonical target flag is
available and is ignored elsewhere. A scoped alias requires its canonical
command and target flag to be installed. The selected canonical command
determines applicable scoped aliases, so the alias also works when that
command is reached through another command path. A scoped mapping takes
precedence over an application-wide mapping with the same spelling.
Only tokens parsed as flag names participate in flag aliasing. Values, operands,
and tokens following `--` remain unchanged. This includes values that look like
flags, such as `--message=--hlep`. Aliased flags use the target's canonical
binding, conversion, default, required-value check, presence, and repetition
behavior. Mixed spellings preserve occurrence order: singleton flags use the
last successfully parsed occurrence, every supplied occurrence is converted,
and repeatable flags accumulate values in order.
Flag aliases accept one long spelling or one individual short spelling. Short
aliases use the canonical short-option cluster and attached-value grammar:
| Input | Interpretation |
| --- | --- |
| `-vq` | Two boolean flags, `--verbose` and `--quiet`. |
| `-cblue` or `-c blue` | Value `blue` for a value-taking `-c` flag. |
| `-vqcblue` | `-v`, `-q`, then `--color=blue`. |
| `-cvq` | Value `vq` for a value-taking `-c` flag. |
| `-v=false` | Explicit false for the boolean flag `-v`. |
| `-c=` | An explicitly supplied empty string. |
Boolean flags use presence as true; an explicit boolean value requires `=`.
The first value-taking short flag consumes the remaining cluster suffix or the
next argument. A consumed value is never interpreted as an alias. The canonical
short-option empty-value form `-c=` supplies an empty value, consistently with
`--color=` and `-c ""`.
Help reached through an alias describes the canonical command's flags and
operands while using the entered route in usage. It identifies the canonical
target, lists applicable aliases separately, and supports global help aliases
such as `--hlep → --help`. Help for an alias-only prefix lists its routes
without creating a command group; the bare incomplete prefix points to that
help. Help and alias registration do not construct command inputs or invoke
acquisition callbacks.
Usage diagnostics preserve the command path and flag spelling the caller used.
When a command alias resolved successfully, usage identifies its canonical
target while retaining the entered path in the diagnostic. An unresolved route
receives no invented canonical label. A flag conversion error identifies the
entered flag spelling and the failing occurrence while preserving the original
cause.
## Configuration scopes
An application installs the declaration returned by
@@ -155,8 +310,8 @@ interrupt the native value. Existing native markers and leading comments are
retained; documents without a marker receive one. Scope labels containing line
control characters are quoted to keep them on one comment line. A provider
failure leaves the output stream empty. A rendering or output failure may leave
an aggregate prefix, and the outcome reports
incomplete report delivery.
an aggregate prefix; `Outcome.Presentation.Output` records incomplete delivery
and the destination or rendering error.
### List configuration sources
@@ -322,8 +477,9 @@ again.
The complete report is prepared before writing. Report preparation failures and
cancellation observed before report delivery starts leave CommandKit's report
output empty. Once writing starts, CommandKit does not interrupt the supplied
writer or roll back delivered bytes. A write error or short write uses the
existing incomplete-report delivery outcome and can leave partial output.
writer or roll back delivered bytes. A write error or short write is recorded
as incomplete delivery in `Outcome.Presentation.Output` and can leave partial
output.
## PromptKit definition inspection
@@ -539,8 +695,9 @@ execution. See PromptKit's
Builder, engine-loading, and native validation errors use the operational path
(status `1`) with supported error identity and acquired diagnostics retained.
Malformed syntax, missing operands, and unsupported formats use usage status
`2`. Successful diagnostics and report-delivery failures follow the common
delivery contract.
`2`. Successful diagnostics use the common reporting policy. Report delivery
failures are recorded in `Outcome.Presentation.Output`, separately from the
operation cause.
### PromptKit catalogs
@@ -734,8 +891,8 @@ outcome. Operation errors use operational status `1`; aggregate runs continue
after individual errors, retain their reports and ordered error text, and join
causes for supported `errors.Is`/`errors.As` access. Cancellation stops later
operations and preserves results already acquired. Report encoding or delivery
failures use the common incomplete-report delivery outcome and retain operation
causes where applicable. Syntax, operand, unsupported format, and conflicting
failures are recorded in `Outcome.Presentation.Output`, separately from
operation causes. Syntax, operand, unsupported format, and conflicting
namespace selection are usage status `2`.
Native configuration, selection, and diagnostic meanings remain owned by
@@ -748,4 +905,9 @@ and [engine API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f
CommandKit usage failures use status `2`; operational failures use status `1`.
Successful operations use status `0`. Application handlers may return a domain
classification and status of their choice, which `Execute` preserves. The caller
chooses how to map an outcome to process exit.
chooses how to map an outcome to process exit. A rendering or delivery failure
changes a successful or usage outcome to operational status `1`. A domain
outcome keeps its classification and nonzero status; a zero-status domain
outcome becomes `1` if presentation fails. The returned `Outcome.Presentation`
records output and diagnostic delivery observations separately from its
operation `Cause`.

View File

@@ -1,9 +1,11 @@
# Consumer guide
CommandKit is distributed as three Go modules. Start with the core and add an
integration module only when the application uses that library. Each module
returns core `commandkit.Command` declarations, which the application installs
through `commandkit.New` or `App.Register`.
integration module only when the application uses that library. Core and
integration command factories return canonical `commandkit.Command` values.
Applications may install those alongside application-owned `RoutingRule`
declarations through `commandkit.New` or `App.Register`; routing rules are
installed at the application root, not as group children.
## Module selection
@@ -62,7 +64,9 @@ func Greeting() commandkit.Command {
),
},
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
_, _ = fmt.Fprintln(invocation.Streams.Out, input.Count.Value)
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
return commandkit.Failure("Could not write the greeting.").WithCause(err)
}
return commandkit.Success()
},
})
@@ -83,6 +87,88 @@ values convert before input construction. A command's `NewInput` creates input
only when execution reaches it; help does not construct caller input or run
provider callbacks.
## Invocation reporting
`Invocation.Reporter()` supplies the reporter for the current handler. Use
`Output()` or `RenderOutput()` for substantive results; use `Warn`, `Info`,
`Progress`, and `Debug` for categorized messages. Queue a summary with
`AtEnd().Info` when it belongs after the handler returns. The
[CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes,
and status behavior.
Return terminal text with `Failure(message, details...)`, `Usage(message,
details...)`, or `WithExplanation` on a domain outcome. Attach an optional Go
error separately with `WithCause`. A cause is preserved for inspection, but it
does not choose the outcome or become user-facing text. Rendering callbacks
write to their supplied writer only while the callback runs; they must not
submit another message or start another rendering operation through the same
reporter.
```go
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
return commandkit.Failure("Could not write the greeting.").WithCause(err)
}
return commandkit.Success()
}
```
After `App.Execute`, inspect the returned `Outcome`. Its `Cause` retains the
operation error; `Presentation.Output` and `Presentation.Diagnostics` report
delivery state and the first rendering or write errors for their destinations.
CommandKit does not retain a message transcript. Reporter operations for one
invocation are safe concurrently, but concurrent submission order is not
specified. The caller owns the writers passed to `Execute`; CommandKit does not
close them.
### Migrating from the previous reporting API
| Previous API | Current API |
| --- | --- |
| `invocation.Streams.Out` / `.Err` | `invocation.Reporter().Output()` and categorized reporter methods. `Streams` remains the argument to `App.Execute`. |
| `Failure(err)` / `Usage(err)` | `Failure("operator-facing explanation").WithCause(err)` / `Usage("usage explanation").WithCause(err)`. Choose the explanation deliberately; the cause alone is not printed. |
| `Outcome.Diagnostics` or terminal `SeverityError` | Submit independent messages through reporter methods. Put failure context in `Explanation`; provider `Acquired.Diagnostics` remains passive data for the owning integration to submit. |
| `ReportIncomplete`, `DiagnosticDelivery`, or `DeliveryError` | Inspect `Outcome.Presentation.Output` and `.Diagnostics`, including each `DeliveryResult.State`, `RenderError`, and `WriteError`. |
## Application-owned aliases
Pass commands and aliases together as declarations. Integration factories can
be called directly in the installation list, and command-scoped flag aliases
name the canonical command path:
```go
app, err := commandkit.New(
identity,
ckprompt.Command(suite),
commandkit.Alias("pk", "promptkit"),
commandkit.FlagAliasFor("promptkit show prompt", "--fmt", "--format"),
)
```
For a reusable collection, use `[]commandkit.Declaration`. Go does not expand a
`[]commandkit.Command` into `...commandkit.Declaration`, so convert command-only
slices when composing them with routing rules:
```go
commands := []commandkit.Command{ckprompt.Command(suite)}
declarations := make([]commandkit.Declaration, 0, len(commands)+1)
for _, command := range commands {
declarations = append(declarations, command)
}
declarations = append(declarations, commandkit.Alias("pk", "promptkit"))
app, err := commandkit.New(identity, declarations...)
```
New and Register validate each supplied batch before publishing its commands
and routing rules. Factories capture their inputs eagerly; malformed or
conflicting rules are reported at installation. Identical rules are safe to
contribute from reusable helpers. The [core example](../../examples/core) and
[composed consumer](../../integrations/ckprompt/examples/composed) show complete
maintained wiring with ordinary command behavior alongside aliases. The
[Go declarations and GoDoc](../../command.go) own the public API contracts;
the [CLI reference](../cli.md#application-owned-aliases) owns routing and
operator-visible behavior.
## ConfigKit scopes
For an application with one ordinary app configuration loader,
@@ -216,10 +302,11 @@ application-owned `session restore` command.
## Outcomes and ownership
Core classifies usage and operational failures while preserving application
domain outcomes and supported error identity. Report and diagnostic delivery
errors remain inspectable separately from the operation cause. The caller owns
process startup, signal handling, and exit mapping; `Execute` never exits the
process.
domain outcomes and supported error identity. Applications choose domain
classification and status. A terminal explanation is presented separately from
the optional operation cause, while report and diagnostic delivery observations
are available in `Outcome.Presentation`. The caller owns process startup,
signal handling, and exit mapping; `Execute` never exits the process.
Go declarations and GoDoc own exact exported contracts. ConfigKit owns
configuration decoding, validation, and provenance. PromptKit owns prompt

View File

@@ -2,8 +2,9 @@
CommandKit is distributed as a core Go module and two optional integration
modules. Core owns captured declarations, typed input preparation, installation,
dispatch, outcomes, help, and version. `integrations/ckconfig` owns ConfigKit
scope adaptation and reports; `integrations/ckprompt` owns PromptKit engine,
dispatch, invocation reporting and outcomes, help, and version.
`integrations/ckconfig` owns ConfigKit scope adaptation and reports;
`integrations/ckprompt` owns PromptKit engine,
definition inspection, requirements discovery, and concrete request validation.
The maintained [consumer guide](consumers/guide.md) links to the three maintained
consumers.
@@ -11,6 +12,12 @@ The [README](../README.md) provides project orientation; the
[documentation policy](policy/documentation.md#canonical-ownership) assigns
canonical documentation owners.
For application-owned aliases, use the
[CLI reference](cli.md#application-owned-aliases),
[Go declarations and GoDoc](../command.go), and
[ADR 0013](adr/0013-application-alias-routing.md). Keep deferred capabilities in
[deferred work](roadmap/deferred.md).
## Initial orientation
1. Inspect the worktree and preserve unrelated staged and unstaged changes.
@@ -47,6 +54,8 @@ canonical documentation owners.
| Task | Read |
| --- | --- |
| Implemented CLI behavior | [CLI reference](cli.md) |
| Invocation reporting | [CLI reporting contract](cli.md#reporting), [consumer guide](consumers/guide.md#invocation-reporting), core GoDoc, [reporting roadmap](roadmap/reporting.md), and [ADR 0014](adr/0014-invocation-reporting.md) |
| Application-owned aliases | [CLI reference](cli.md#application-owned-aliases), [Go declarations and GoDoc](../command.go), [consumer guide](consumers/guide.md#application-owned-aliases), and [ADR 0013](adr/0013-application-alias-routing.md) |
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [ckconfig package](../integrations/ckconfig/doc.go), [ckprompt package](../integrations/ckprompt/doc.go), and applicable ADRs |
| Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md), refined by [ADR 0011](adr/0011-captured-declarations-and-installation.md) |
| Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-cobra-backend.md) |
@@ -63,7 +72,8 @@ Read all applicable rows for cross-cutting work. Go declarations and GoDoc own
current exported contracts; the CLI reference owns operator behavior. The
[maintainer release procedure](release.md) owns current release operations.
Completed roadmaps and implementation evidence remain available in Git history,
including [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
including [application-owned alias planning, implementation, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/f42aefe02d84e154855ac922173e6f367180929f/docs/roadmap)
and [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
These records are not assignments to restart completed work. The
[deferred-work record](roadmap/deferred.md) preserves future capability targets
and unselected proposals separately from current contracts.

View File

@@ -8,18 +8,55 @@ this page inventories the implemented owners.
## Root `commandkit`
The root facade owns captured command declarations, binding conversion,
invocation coordination, the per-App registry, standard help/version commands,
and outcome/diagnostic delivery. Its dependency graph has no ConfigKit or
PromptKit integration dependency. Applications provide typed callbacks and
choose when to install declarations.
The root facade owns captured canonical command declarations, application-owned
routing rules, binding conversion, invocation coordination, the per-App
registries, standard help/version commands, invocation reporting, and outcome
finalization.
Its dependency graph has no ConfigKit or PromptKit integration dependency.
Applications provide typed callbacks and install routing rules alongside the
complete canonical command tree through the same batch API.
The shared command compiler prepares supplied values before constructing input.
The batch installer validates retained issues, placement, and collisions before
mutating the registry. Each invocation uses one routing snapshot and backend
session; a successful registration invalidates the snapshot for a later
invocation. The root package owns app input and callback execution, not provider
configuration semantics.
Canonical commands own handlers and parsing/help metadata; routing rules resolve
entered command paths and flag spellings to those canonical records without
adding command nodes or handlers. The shared command compiler prepares supplied
values before constructing input. The batch installer validates retained
issues, placement, and collisions across both registries before publishing
either. Each invocation uses one routing snapshot and backend session; a
successful registration invalidates the snapshot for a later invocation. The
root package owns app input and callback execution, not provider configuration
semantics.
Each execution creates one reporter backed by the caller's result and diagnostic
writers. The Cobra adapter returns parsed values, flag spellings, and reporting
flag occurrences. Core reduces those occurrences in entered order after route
resolution, applies the final mode and progress selection, and presents any
collected parser events through that selection. The selected mode therefore
applies to command execution, help, and version; routing and parsing failures
remain visible in every mode.
The reporter owns invocation-scoped visibility, message formatting, managed
report rendering, delivery observations, and finalization. It presents queued
messages after the handler returns, then a failed outcome's explanation, and
closes its handles before `Execute` returns. It records rendering and write
errors separately for result and diagnostic destinations; the returned
`Outcome.Presentation` exposes those observations independently from the
operation cause. The reporter does not retain a transcript and does not close
or flush caller-owned writers.
Internally, one reporting-state mutex guards visibility, queued messages,
destination state, and delivery errors while serializing reporter operations
and writes. Rendering callbacks execute within that coordination boundary, so
they must not recursively submit or render through the same reporter. This is
an implementation detail supporting the callback contract; the mutex does not
coordinate writes made outside the reporter.
## `internal/alias` and `internal/commandpath`
`internal/commandpath` provides shared path parsing and token operations used by
declaration capture, routing, and backend selection. `internal/alias` compiles
application routing rules, resolves command prefixes while retaining token
provenance, and validates command-route termination. Neither package owns
handlers, application factories, or backend types.
## `internal/commandmeta`

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.3
**Last Updated**: 2026-10-11
**Last Updated**: 2026-10-26
**Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
@@ -75,26 +75,26 @@ Its documentation recommends the same grammar and vocabulary for application-own
commands, including those outside its dispatcher, under
[ADR 0006](../adr/0006-command-grammar.md).
CommandKit preserves application-supplied outcome classifications and supported
error identity. It assigns responsibility for its diagnostics, avoids duplicate
presentation through its own paths, and reports write failures. Caller callbacks
retain control of their own output. [ADR 0005](../adr/0005-invocation-outcomes.md)
describes the outcome and delivery model.
Use explicit instance construction and composable command registration, not
package-global or import-time registries. Applications and integrations supply
the same captured command declarations through the core installation API.
captured canonical command declarations through the core installation API.
Declaration factories capture metadata, bindings, and callback references eagerly;
construction errors remain attached for installation to report. Capture the
metadata collections needed to keep later edits to a source declaration from
changing a returned command. Application callback objects retain their ownership.
One definition supplies routing, parsing, and help metadata.
Canonical command declarations own handlers, parsing metadata, and help
metadata. Applications may provide separate routing rules in the same
installation batch; these rules resolve invocations to canonical commands and
do not add command nodes, handlers, or flags.
Validate each installation batch before mutating the instance registry. A failed
construction yields no usable application; a failed later registration leaves the
existing registry intact. Separate successful registration calls are not one
transaction. Duplicate paths and attempts to replace reserved commands report
errors rather than letting registration order select a winner.
transaction. Duplicate canonical paths and attempts to replace reserved commands
report errors rather than letting registration order select a winner. Identical
routing rules are idempotent; competing targets for the same source and scope
report an error. Command-scoped flag routing refers to an exact canonical
command path.
Explicit absent-command declarations contribute no commands, help entries, or
namespace claims; attached errors are reported before skipping absence. An invalid
@@ -121,6 +121,30 @@ inspection/execution loading and focused acquisition callbacks are recommended
integration practices. Independent consumer use of other CLI frameworks remains
application-owned.
## Invocation Reporting And Outcomes
Use one reporting facility per invocation for command results, immediate messages,
and deferred messages. Core owns shared visibility, formatting, delivery, and
finalization. Applications and integrations choose their report content and
messages; callers choose the destinations. [ADR 0014](../adr/0014-invocation-reporting.md)
extends the outcome and delivery model in [ADR 0005](../adr/0005-invocation-outcomes.md).
Applications decide how to respond to domain-specific errors. CommandKit may
provide shared helpers for carrying, inspecting, and presenting errors while
leaving those decisions with the application. Supplying an error cause alone
does not select an outcome or generate an operator-facing message. CommandKit
handles its own errors, including routing, parsing, and presentation failures.
Preserve application-defined domain classifications and supplied error identity.
Report rendering and delivery failures separately from the command's explanation
and cause, applying the outcome rules in ADR 0005 and ADR 0014. Intentionally
suppressed output is not a delivery failure. Present terminal failure explanations
after normal reporting finishes, avoid duplicate presentation through core's own
paths, and retain the explanation and any supplied cause in the returned outcome.
Keep reporting state scoped to the invocation's presentation and returned outcome.
Logging and message history remain application concerns.
## Handler Inputs And Invocation State
Handlers receive typed input structs with explicit flag and operand bindings.
@@ -209,8 +233,8 @@ transforming arbitrary application objects.
Standard contracts such as `context.Context` and `io.Writer` remain appropriate.
Upstream public types may be selected as deliberate dependencies in the
integrations that own them; that permission does not justify optional library
dependencies in the core. Core diagnostics use a CommandKit-owned severity type,
and integrations convert upstream diagnostics while preserving selected outcome
dependencies in the core. Core owns shared presentation categories,
and integrations adapt upstream diagnostics while preserving selected outcome
and error semantics. Application-owned input types remain application-owned.
Expose narrow core composition contracts for demonstrated integration needs,
without exporting private assembly or backend structures.

74
docs/releases/v0.3.0.md Normal file
View File

@@ -0,0 +1,74 @@
# CommandKit v0.3.0
## Summary
CommandKit v0.3.0 adds application-owned command and flag aliases and shared
invocation reporting. This pre-1.0 minor release also updates the installation
declaration type and Invocation struct shape, replaces the error-first outcome
and diagnostic-delivery APIs, and reserves reporting flag names; review the
migration guidance before upgrading.
## Changes
- Declare command shortcuts and global or canonical-command-scoped flag
spellings with `Alias`, `FlagAlias`, and `FlagAliasFor`. Install routing rules
beside canonical commands through `New` or `App.Register`. Aliases route to
existing handlers and preserve the target command's operand and binding
behavior.
- Read the entered and canonical command paths from `Invocation` with
`EnteredPath` and `CanonicalPath`.
- Interpret a canonical short value flag such as `-c=` as an explicitly
supplied empty value, consistent with `--color=` and `-c ""`.
- Obtain an invocation-scoped `Reporter` from each handler's `Invocation` for
substantive output, Warning/Info/Progress/Debug messages, deferred messages,
and managed report rendering. The shared `--quiet`, `--verbose`, `--debug`,
and `--progress` flags apply across core and integration commands.
- Return terminal explanation text with `Failure`, `Usage`, or
`Outcome.WithExplanation`; attach an optional operation error separately with
`Outcome.WithCause`. Returned `Outcome.Presentation` records output and
diagnostic delivery observations without changing the operation cause.
## Compatibility and migration
`New` and `App.Register` now accept `...Declaration` instead of `...Command`.
Command, group, and integration command factories still return `Command`, so
individual factory calls can be passed directly. A `[]Command` cannot be
expanded into `...Declaration`; use a `[]Declaration` for mixed reusable
collections or convert each command element before expanding it. Function
values whose parameter type is `...Command`, including wrappers assigned from
`New` or `Register`, must update their signature to `...Declaration`.
Adding private route fields to `Invocation` means positional struct literals no
longer compile. `Invocation` no longer contains `Streams`; `App.Execute` supplies
the reporter and route paths to handlers. `Streams` remains the argument to
`App.Execute`, where the caller owns the result and diagnostic writers.
Handlers that used `invocation.Streams.Out` or `.Err` should use
`invocation.Reporter().Output()` or the categorized reporting methods. Replace
`Failure(err)` and `Usage(err)` with an operator-facing explanation and an
optional `.WithCause(err)`; causes alone are not rendered. Replace
`Outcome.Diagnostics` submissions and terminal `SeverityError` records with
reporter messages and an `Outcome` explanation. Provider-returned
`Acquired.Diagnostics` remain passive data for the owning integration to
submit. Replace `ReportIncomplete`, `DiagnosticDelivery`, and `DeliveryError`
with `Outcome.Presentation.Output` and `.Diagnostics`, whose `DeliveryResult`
contains a state plus first render and write errors.
The long flags `--quiet`, `--verbose`, `--debug`, and `--progress` are reserved
for CommandKit reporting and cannot be declared by applications. Application
aliases may target them. See the current [CLI reporting contract](../cli.md#reporting)
and [consumer migration guide](../consumers/guide.md#invocation-reporting) for
their behavior and API examples.
The consumer minimum remains Go 1.26.0 and the maintainer release toolchain
remains Go 1.26.8. Core v0.3.0 must be published before preparing dependent
integration releases that adopt it and record downloaded dependency sums; this
release does not change their v0.1.0 release records.
See the [consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.3.0/docs/consumers/guide.md),
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.3.0/docs/cli.md),
and [maintainer release procedure](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.3.0/docs/release.md).
[COPYRIGHT](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.3.0/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.3.0/LICENSE) |
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.3.0/docs/oss/attribution.md)

View File

@@ -0,0 +1,820 @@
# Invocation reporting implementation plan
Status: Stages 1–20 complete.
## Objective and authority
Implement the end state selected in the [reporting roadmap](reporting.md), under
[ADR 0014](../adr/0014-invocation-reporting.md),
[ADR 0005](../adr/0005-invocation-outcomes.md), and the
[architecture policy](../policy/architecture.md). The roadmap owns the selected
behavior and target API; this document owns the steps from the current code to
that end state. Read those sources before starting.
CommandKit owns presentation, invocation coordination, and its own failures.
Applications own their domain responses and selected outcomes. ConfigKit and
PromptKit own their native semantics, diagnostic evidence, and disclosure rules.
The [testing policy](../policy/testing.md#upstream-integration-testing-ownership)
applies to every stage: test CommandKit's adaptation and presentation, without
retesting upstream functionality.
## Sprint instructions
- Follow the stages in order. Each stage is sized as a separate Luna task.
Read the entire plan initially and the owning roadmap sections for each stage.
- This is one end-to-end sprint. Intermediate stages need not compile or leave
the repository coherent. Reach the clean final API directly; do not introduce
compatibility constructors, duplicate submission paths, or temporary adapters
merely to make intermediate commits buildable.
- Preserve behavior outside the selected reporting changes, including command
and flag aliases, typed input semantics, provider lifetimes, scope selection,
native reports, and optional-module dependency direction.
- Inspect existing tests before adding coverage. Migrate tests whose contracts
intentionally change; retain protection for unaffected behavior. Each stage's
validation list identifies risks to protect, not a requirement for one test
per bullet or an exhaustive cross product.
- Add or revise focused tests with the owning implementation. Run executable
focused checks when their dependencies are ready. Record deferred validation
accurately; a broken intermediate build is not evidence of a passing test.
Stage 20 validates the assembled repository.
- Follow the [agent policy](../policy/agents.md), including focused local commits
and preservation of unrelated changes. Update this plan's stage status as work
completes. Do not push, tag, publish, or retire these planning documents as
part of this sprint.
## Baseline and implementation boundaries
At planning baseline `1d29cdd`, core handlers receive raw `Invocation.Streams`.
`Outcome` combines causes, queued diagnostics, and older delivery fields.
`execution.present` coordinates final diagnostics. The Cobra adapter returns
per-binding flag values and spellings, but not their order across different
flags. Reporting modes and an invocation reporter do not exist.
Core remains responsible for the public reporter, outcome types, visibility,
delivery accounting, flag policy, and automatic finalization. Keep ordinary
private implementation beside the root facade where appropriate; introducing a
new public package is unnecessary. `internal/commandmeta` carries neutral parser
metadata, and `internal/cobra` continues to contain Cobra/pflag-specific code.
`integrations/ckconfig` owns ConfigKit adaptation and configuration command
presentation. `integrations/ckprompt` owns PromptKit adaptation and commands and
may use ckconfig. Neither integration becomes a dependency of core, and ckconfig
does not import ckprompt. Providers return passive data; they do not acquire a
reporter parameter.
Use one shared private state per reporter and serialize operations with a mutex.
Use synchronous writes rather than adding a buffering or worker subsystem.
Render callbacks receive a writer for the already-coordinated operation; that
writer must not reacquire the reporter lock. Callback reentrancy restrictions
are those selected in the roadmap, without runtime detection machinery.
## Stage 1: Replace outcome and provider data declarations
Status: complete.
Primary files: [outcome.go](../../outcome.go) and their focused contract tests.
1. Implement the exact `Explanation`, `Outcome`, `PresentationResult`,
`DeliveryResult`, and `DeliveryState` declarations selected in the roadmap.
Use the selected `uint8` delivery enumeration, including `DeliverySuppressed`.
Remove the superseded outcome diagnostic/delivery fields.
2. Implement `Success`, `Failure(message, details...)`, `Usage(message,
details...)`, `Domain(name, status)`, `WithExplanation`, and `WithCause`.
Copy accepted detail slices so replacing or modifying the caller's slice
does not change a captured callback list. Callback-captured data remains
caller-owned. Constructors and modifiers emit nothing.
3. Preserve optional `Cause` unchanged. It does not select status, category, or
explanation. Preserve zero-value outcome success without treating a cause
as a reason to infer failure. `WithExplanation` preserves cause;
`WithCause` preserves classification, status, and explanation.
4. Add `Explanation` to `Acquired[T]`. Keep `Diagnostic` as passive severity and
renderer data. Retain `SeverityWarning` and add `SeverityInfo`,
`SeverityDebug`, and `SeverityProgress`; remove `SeverityError` because
terminal content now belongs to `Explanation`. Retain `MessageDiagnostic`
as a passive literal-message renderer helper, without emitting or selecting
an outcome.
5. Declare `ErrReporterClosed` with the selected error text. Do not retain the
old API through overloaded or parallel constructors.
Validation: protect constructor/modifier independence, copied callback lists,
non-emission, and cause identity using `errors.Is` and `errors.As`. Existing
callers are migrated in later stages.
## Stage 2: Implement reporter state and managed output writers
Status: complete.
Primary files: new private reporting implementation in core, plus
[identity.go](../../identity.go) as needed for internal wiring.
1. Add the public `Reporter` facade backed by one private invocation state.
Its private constructor accepts caller-selected destinations and starts in
ordinary mode. Keep `Streams` as the `App.Execute` input; preserve existing
nil-destination normalization.
2. Implement `Output()` using a managed `io.Writer`. Serialize each write,
preserve ordinary writer return behavior, and record the first destination
failure even if the handler ignores it. Normalize a short write with nil
error to `io.ErrShortWrite`.
3. Track result and diagnostic destinations independently. A failed destination
receives no more writes; a healthy destination remains usable. Keep the
first render error and first write error per destination, without a history
or content buffer.
4. Implement private visibility state for ordinary, quiet, verbose, and debug,
plus explicit progress value and presence. Quiet output writes discard
bytes successfully without calling the supplied destination.
5. Aggregate delivery observations according to the roadmap: incomplete when
rendering or writing failed; otherwise complete when requested delivery
completed, suppressed when all submitted content was hidden, and none when
no content was submitted. Merely obtaining an output handle submits nothing.
6. Retained output handles consult shared lifecycle state on every write.
After closure they return `ErrReporterClosed`, including under quiet mode.
Do not close or automatically flush caller-owned destinations.
Validation: use controlled writers to cover visible and suppressed output,
short writes, ignored errors, bounded first-error retention, destination
independence, and retained-handle closure. No concurrent `App.Execute` support is
introduced by reporter synchronization.
## Stage 3: Implement atomic rendering and categorized messages
Status: complete.
1. Implement `RenderOutput`, `Info`, `Warn`, `Debug`, and `Progress` with the
exact roadmap signatures. A render operation or complete categorized message
owns the reporting lock for its heading and all details.
2. Render callbacks through their supplied writer. The internal writer records
destination failures without recursively locking the reporter. Multiple raw
`Output().Write` calls remain separate operations.
3. Implement literal headings, the selected prefixes and channels, newline
termination, empty-heading behavior, and ordered details. Skip nil details.
Preserve native multiline content and distinct submissions with equal text.
4. Apply the selected visibility table and progress precedence. Suppressed rich
renderers execute against discard initially; simple hidden headings need no
formatting or destination write. Do not retain hidden content.
5. Record callback failures as presentation failures even when ignored. Distinguish
them from sink failures: a callback error alone does not mark the destination
unusable. A propagated managed-writer error is evidence of the write failure,
not a second independently discovered rendering failure; independently
returned renderer errors remain available separately.
6. Keep the directly invoked encoder boundary: an encoder called by application
code on `Output()` owns its encoding error, while managed writer errors are
still tracked. CommandKit owns callback errors from `RenderOutput` and details.
Validation: cover category visibility and destination, prefixes, empty/native
headings, detail order, rendering failure before any write, rendering failure
under quiet mode, and independent rendering/write failures. Exercise concurrent
complete messages and rendering operations with deterministic synchronization;
assert intact operations without imposing an order among concurrent callers.
## Stage 4: Implement deferred delivery and finalization
Status: complete.
1. Implement `AtEnd` and the selected value-type `DeferredReporter` methods.
Copy callback lists on submission. Queue messages in submission order,
without sorting by severity or retaining a delivered transcript.
2. Implement one private finalization path: drain eligible deferred messages;
finish ordinary reporting including the completion event supplied later;
account for failures; present the terminal explanation; close the reporter;
populate and return `Outcome.Presentation`.
3. Deferred messages remain eligible on failure. Handler Go defers run before
finalization. Applications finish reporting goroutines before returning;
this is not a task-waiting or cancellation subsystem.
4. Before terminal delivery, insert and retain `Command failed.` only for a
non-zero status with an empty heading and no non-nil detail callback.
Trust a supplied renderer even if it emits no bytes. Do not inspect its
output by buffering or replaying it.
5. Apply the selected failure table: any presentation failure changes a standard
outcome, including usage, to operational status `1`. Preserve domain name
and non-zero status; change domain status `0` to `1`. Preserve the original
explanation and optional cause, recording presentation evidence separately.
6. If presentation failure turns success into failure without an explanation,
supply a useful core-owned explanation through a healthy diagnostic
destination. Never derive application text from `Cause`, redirect failed
diagnostics into results, or recursively report a broken diagnostic sink.
7. Terminal delivery failures update observations and outcome classification
without scheduling another terminal explanation. Close every shared handle
before returning. Clear consumed deferred/parser state.
Validation: cover immediate/deferred/terminal ordering, deferred failures,
generic fallback and retention, explicit zero-byte native explanations,
success/usage/operational/domain transitions, preserved causes and explanations,
healthy diagnostic delivery following renderer failure, and closed retained
reporter/deferred/output handles. Include failures during terminal delivery.
## Stage 5: Install shared reporting flag metadata
Status: complete.
Primary files: [command.go](../../command.go),
[parser.go](../../parser.go), [flag_aliases.go](../../flag_aliases.go),
[help_view.go](../../help_view.go), and
[internal/commandmeta/metadata.go](../../internal/commandmeta/metadata.go).
1. Define shared neutral metadata for the four long boolean flags: `quiet`,
`verbose`, `debug`, and `progress`. Supply it to every parse/help context:
root, canonical groups, runnable commands, built-ins, and alias-only discovery.
Keep Cobra/pflag declarations inside the adapter.
2. Extend reserved-name validation to those four long names. Preserve capture
error retention, installation collision reporting, and batch atomicity,
including later registration. Do not reserve unselected short spellings.
3. Include core flags among alias targets and canonical spelling collisions in
every applicable context. Applications may register their own short aliases
through the existing API; core supplies none for reporting flags.
4. Keep reporting flags separate from consumer typed bindings. Do not shift
`BindingSet` identities or change the number/order of values supplied to a
captured input preparer.
5. Extend help metadata without manufacturing canonical nodes for discovery
contexts or changing entered/canonical identity rules.
Validation: protect installation collisions and atomicity, aliases to each core
flag, canonical-spelling collisions, unchanged typed binding indexes, and flag
availability in root/group/built-in/discovery help.
Focused metadata and Cobra adapter tests pass. Root-package registration and
alias tests are included, but their execution is deferred until the planned API
migration restores root-package buildability.
## Stage 6: Preserve occurrence order through the normal parser
Status: complete.
Primary files: [internal/cobra/flags.go](../../internal/cobra/flags.go),
[internal/cobra/parse.go](../../internal/cobra/parse.go), and neutral metadata.
1. Extend the neutral parse result with ordered flag-occurrence evidence carrying
canonical flag identity, entered spelling, and raw value. Keep existing
per-binding values and spelling information for typed inputs.
2. Record occurrences when the normal parser consumes flags, using the existing
alias interpreter and raw-value collection. Do not add a second raw-argv
scanner for verbosity. Preserve the consumed prefix when parsing fails;
do not invent occurrences after the failing token.
3. Reduce reporting occurrences in argv order. Convert every supplied boolean,
including overwritten occurrences. True selects its mode; false clears only
the currently selected matching mode to ordinary. Track the last explicit
progress value and its presence independently.
4. Ensure aliased and canonical spellings share one order, including existing
short-alias clusters and boolean syntax. Keep option-looking flag values,
operands after `--`, and ordinary consumer flag values unchanged.
5. Preserve command-path placement rules, interspersed operands after the
complete path, existing failure precedence where unaffected, and help
boolean handling. Unknown flags and invalid booleans remain usage failures.
6. Make the resolved presentation selection and relevant neutral parser events
available to core before output or input factories run. Partial selection
on failure must not affect unconditional terminal-error visibility.
Validation: own the dense ordering cases at the parser/reducer boundary: every
roadmap reset example, mixed aliases, repeated booleans, invalid earlier values,
explicit progress before/after mode flags, values containing reporting-like
tokens, terminators, and malformed paths. Retain coverage for input spelling
provenance and consumer binding conversion.
Focused internal package suites pass. Root reducer tests are included, but their
execution is deferred until the planned API migration restores root-package
buildability.
## Stage 7: Connect invocation execution to one reporter
Status: complete.
Primary files: [identity.go](../../identity.go),
[execute.go](../../execute.go), [route.go](../../route.go),
[flag_values.go](../../flag_values.go), and their invocation tests.
1. Replace handler-facing `Invocation.Streams` with a private reporter reference
and the public `Invocation.Reporter()` method. Retain context and entered/
canonical accessors. `App.Execute` continues accepting caller `Streams`.
2. Construct one reporter for every invocation, including paths that fail before
a handler exists. Apply parsed selection before successful output, input
preparation, providers, or the handler.
3. Replace old `present` bookkeeping with the single finalization path. Route
every normal return through it: nil application/context errors, route and
parse failures, preparation failures, group/help results, and handler outcomes.
4. Convert CommandKit-owned errors deliberately into the new message-first
constructors, preserving applicable cause identity with `WithCause`.
Preserve `ErrFlagInput`/`ErrFlagWiring` classification and input validation.
Do not use a generic rule that turns any application cause into terminal text.
5. Attach usage guidance as terminal explanation detail, visible under quiet.
Preserve entered alias paths, custom discovery usage, and the distinction
between unresolved routes and canonical command identity.
6. Populate presentation observations from actual reporter state rather than
accepting handler-supplied observation fields as delivery evidence.
Preserve fresh input/state lifetimes and registration from handlers; a
refreshed registry snapshot must not replace the invocation's reporter.
Validation: exercise early failures, handler success and domain outcomes,
cleanup writes, ignored writer failures, repeated invocations, alias identity,
and usage guidance in quiet. Preserve factory-free help/preflight behavior and
existing input conversion/cause tests under the new API.
The invocation now owns one reporter, applies ordered reporting selection before
preparation and handler execution, and finalizes normal outcomes through the
reporter. Focused internal suites pass. Root-package execution remains blocked
by the planned help/version migration and remaining legacy test callers; the
root test command was run and its compile errors are recorded for those later
steps. Focused invocation coverage has been added for reporter freshness,
selection, quiet usage guidance, early errors, and ignored output failures.
## Stage 8: Migrate help and version presentation
Status: complete.
Primary files: [builtins.go](../../builtins.go), help metadata/rendering,
and built-in consumer tests.
1. Route successful help and version results through substantive reporting.
Quiet success emits neither report nor a terminal explanation.
2. Keep help-path validation and invalid component/version selection as usage
handling. An unknown help target must not become a rendering failure merely
because help uses `RenderOutput` for presentation.
3. Put actual help/version rendering and delivery under managed reporting,
using the supplied callback writer. Preserve the internal backend boundary
and discovery help metadata. Render quietly into discard where required.
4. Keep application version as the default and existing explicit component
selection intact. Do not add new version scopes or dependency inspection.
5. Remove built-in paths that write directly to invocation streams or manually
populate superseded incomplete-report fields.
Validation: cover `help --quiet`, `version --quiet`, canonical and alias help,
alias-only discovery, shared reporting flags, invalid help/version requests,
factory-free behavior, and help/version destination failures. Invalid requests
remain usage outcomes unless presentation subsequently fails.
Help and version reports now render through the invocation reporter, including
quiet-mode rendering and destination-failure accounting. Tests cover quiet
success, canonical and alias help, alias-only discovery, invalid requests, and
destination failures. The internal suites pass; root-package execution remains
deferred because the existing external test package still references APIs
scheduled for the later suite reconciliation.
## Stage 9: Add core debug events
Status: complete.
1. Instrument routing, command-alias resolution, verbosity selection, and handler
completion. Explain the relevant entered/canonical paths and decisions.
Include flag-alias identity when it explains reporting selection.
2. Collect early routing/parser events temporarily and submit them only after
final mode selection. Clear them after submission; do not add a transcript.
3. Submit the completion event after the handler returns and after deferred
messages, before terminal presentation. Describe the handler's classification
and status; the returned outcome remains authoritative after delivery.
4. Use shared Debug reporting for events and normal presentation failure handling
for their delivery. Do not dump argv values, configuration values, causes, or
provider objects. Installation errors remain ordinary returned Go errors.
5. Other candidate events in the roadmap are opportunities, not mandatory
instrumentation for every internal step. Do not turn supported repetition
into a warning.
Validation: `--verbose --debug` reveals earlier selection events;
`--debug --quiet` hides them. Cover alias paths, completion before terminal
explanation, absence of completion when no handler runs, and debug delivery
failure. Assert selected meaning and order rather than incidental prose.
Routing, reporting-mode selection, and handler-completion events now use shared
Debug reporting. Early events are submitted after mode selection, and completion
is submitted after deferred messages. Focused tests cover alias identity,
selection order, quiet suppression, completion ordering, no-handler behavior,
cause privacy, and debug delivery failure. Internal suites pass; execution of
root tests remains deferred until the legacy external callers are reconciled.
## Stage 10: Separate ConfigKit provider messages and explanations
Status: complete.
Primary files:
[configkit_adapter.go](../../integrations/ckconfig/configkit_adapter.go),
[contracts.go](../../integrations/ckconfig/contracts.go), and adapter tests.
1. Migrate `FromConfigKit`, comparison acquisition, and diagnostic helpers to
`Acquired.Diagnostics` plus `Acquired.Explanation`. Public provider callbacks
remain passive and retain their existing context/request/input/state shape.
2. Replace highest-severity whole-batch promotion. Partition native records into
independent nonterminal messages and identified terminal context. Native
error records belong to the explanation; independent warnings remain warnings.
Preserve explicitly related native context with its owning record.
3. Preserve native diagnostic writers, source identifiers/legends, selected
scope and comparison-side context, safe disclosure, and record order within
each resulting presentation path. Snapshot renderer inputs as needed for
deferred use; do not infer relationships from text or error-chain wording.
4. Preserve data returned alongside errors and supported Go error identity.
A native terminal explanation must not also be submitted as an independent
diagnostic or repeated through a generic cause-derived message.
5. Preserve comparison left/right preparation and failure behavior. Carry
already-acquired independent messages through a later acquisition failure.
Validation: adapter tests establish faithful record separation, source/side
context, passive acquisition, retained causes, and data alongside errors.
Use native results as fixtures without testing ConfigKit's resolution or
diagnostic classification algorithms.
The ConfigKit adapter now carries independent warnings and terminal native
records separately, preserves partial values and wrapped errors, and snapshots
native writer inputs. Focused tests cover mixed records, source and comparison
side context, related locations, partial values, error identity, and messages
retained through cancellation. The ckconfig suite cannot run yet because
`diff.go`, `explain.go`, and `family.go` still use APIs scheduled for later
migration; Stage 20 will validate the assembled modules.
## Stage 11: Migrate configuration show and validate
Status: complete.
Primary files: [show.go](../../integrations/ckconfig/show.go),
[validate.go](../../integrations/ckconfig/validate.go), and
[operations.go](../../integrations/ckconfig/operations.go).
1. Introduce small integration-private helpers to submit passive diagnostics to
the corresponding reporter methods, using the deferred view for end-of-run
warnings/context. Native fully formatted renderers use empty headings.
Progress, if present, is immediate; no deferred Progress API is added.
2. Replace old `configOperationFailure`/incomplete-report bookkeeping with explicit
explanations, optional original causes, and reporter-owned delivery evidence.
Use an acquired native explanation when available; otherwise select a useful
integration-owned operation explanation. Do not add a second cause printout.
3. Migrate `config show` to managed rendering. Preserve loading all selected
views before any report output, single-scope native YAML, and ordered
`--scope all` framing, including existing directive/document handling.
Keep necessary per-document buffers; do not introduce whole-run buffering.
4. Include serialization, native rendering, and framing inside the managed
rendering operation so callback failures are recorded under quiet as well.
Report additional messages outside callbacks through the reporter.
5. Migrate `config validate`, including selected-scope iteration, result records,
acquisition failure, and terminal explanation. Preserve existing request and
invocation-state lifetimes, cancellation, and preflight selection.
Validation: preserve show YAML/framing and load-before-output protections.
Add representative mixed warning/failure visibility in ordinary/verbose/quiet,
deferred context ordering, rendering failures, and report write failures.
Update tests of superseded outcome fields to observable reports and
`Presentation`, rather than dropping their original failure protection.
Configuration show and validate now submit provider messages through the shared
reporter and render results through managed output. Tests retain acquisition
ordering, shared state, YAML framing, cancellation with partial validation
output, and cover warning visibility, terminal context, quiet rendering, and
destination failures through `Presentation`. The ckconfig tests remain
unrunnable because `sources.go`, `explain.go`, `diff.go`, and family wiring and
tests still reference APIs scheduled for later migrations; Stage 20 will run the
assembled suites.
## Stage 12: Migrate configuration sources, explain, and diff
Status: complete.
Primary files: [sources.go](../../integrations/ckconfig/sources.go),
[explain.go](../../integrations/ckconfig/explain.go),
[diff.go](../../integrations/ckconfig/diff.go), and remaining report helpers.
1. Migrate remaining commands to the same reporting helpers and deliberate
operation explanations. Preserve scope availability, selection, source
evidence, redaction, and ConfigKit-owned comparison semantics.
2. Wrap the complete CommandKit-owned report rendering in `RenderOutput`,
including preparation/encoding that can fail before destination writes.
Keep existing safe diff YAML and changed/deleted-field presentation.
3. Submit independent provider messages once and attach terminal explanation
only to the returned failed outcome. Preserve partial results already
delivered according to each command's existing behavior.
4. Remove obsolete integration delivery flags, outcome diagnostic attachment,
manual writer tracking, and helpers superseded by the shared reporter.
Preserve meaningful operation causes; do not assign a presentation error as
the operation cause merely to reproduce old bookkeeping.
Validation: retain comparison preflight, selector, safe-report, and native
forwarding tests. Cover a representative failure on each destination and quiet
rendering without duplicating core's complete visibility matrix in every command.
Configuration sources, explanation, diff, and scope inventory now submit
provider messages through the shared reporter and render through managed output.
Native explanation rendering retains already-written partial output, while
renderer and destination failures are recorded in `Presentation` independently
of operation causes. Focused ckconfig package tests pass, including comparison
preflight, source evidence, safe reports, native forwarding, warning/context
ordering, quiet rendering, and result/diagnostic destination failures. The full
ckconfig module command still fails to compile `examples/simple`, which uses the
old invocation API and is scheduled for migration in Stage 17.
## Stage 13: Migrate PromptKit acquisition adapters
Status: complete.
Primary files: [adapters.go](../../integrations/ckprompt/adapters.go),
[validation.go](../../integrations/ckprompt/validation.go),
[smoke.go](../../integrations/ckprompt/smoke.go), and provider contracts.
1. Carry passive independent diagnostics and terminal explanation separately
through engine loading, definition/requirements acquisition, request
validation, and smoke acquisition. Use a private acquisition bundle where
necessary to avoid losing either field between helpers.
2. Replace mixed-batch severity promotion while preserving native rendering,
primary/related source context, source legends, scope/side labels, and
disclosure. Keep unrelated warnings out of terminal explanations.
3. Update `ConfigScope` and engine comparison to use the migrated ckconfig
contracts. Keep dependency direction ckprompt to ckconfig.
4. Preserve original returned errors, useful reports alongside errors, native
validation/smoke evidence, builder/preflight order, and selected request
lifetimes. Providers remain independent of the reporter.
5. Use upstream APIs as adopted. Do not add probes, diagnostic interpretation,
extra inference calls, capability warnings, or newly required configuration.
Validation: establish correct forwarding, passive data, separated context,
and supported error identity. Preserve offline fixtures and existing builder/
acquisition lifecycle protections. No live provider or credentials are needed.
PromptKit engine loading now separates independent warning batches from native
terminal records and carries both through catalogs, inspection, requirements,
ConfigScope comparison, concrete request validation, and smoke acquisition.
Native rendering retains scope/side headings, source legends, and diagnostic
locations; original errors and reports returned with errors remain available.
Offline adapter tests cover mixed record separation, writer propagation,
comparison-side context, error identity, and existing request lifetimes. The
ckprompt suite cannot execute yet: compiler diagnostics identify legacy
reporting calls in `command.go`, `smoke_command.go`, and their tests, which are
scheduled for Stages 14 and 15. No compile errors were reported in the migrated
adapter files; Stage 20 will run the assembled suite.
## Stage 14: Migrate PromptKit listing and inspection commands
Status: complete.
Primary file: [command.go](../../integrations/ckprompt/command.go) and its
catalog, definition, and requirements report helpers/tests.
1. Migrate catalog listing, definition inspection, and requirements inspection
to the reporter, replacing raw stream writes and old `definitionFailure`
diagnostic bookkeeping.
2. Submit independent diagnostics once through small integration-private
category helpers; use acquired terminal context on failure and preserve
optional original causes. Native labelled detail uses an empty heading.
3. Enclose report preparation, native rendering/encoding, and delivery in
managed rendering where CommandKit invokes them. Quiet runs still discover
report-generation failures without touching caller result destinations.
4. Preserve native optional/version fields, text/YAML formats, selection,
aliases, request bindings, and command availability. The reporting feature
does not change what an inspection means or which APIs it invokes.
Validation: migrate existing report/selection tests; add representative
successful quiet/verbose/debug execution and native acquisition failure with an
independent warning. Reuse core reporting coverage for universal mechanics.
PromptKit catalog listing, definition inspection, and requirements inspection
now submit provider messages through the reporter and render complete reports
inside managed output. Acquisition failures use native terminal context when
available and preserve the original cause. Tests cover independent warning and
terminal-context delivery, successful ordinary/quiet/verbose/debug execution,
quiet encoding failure, cancellation, and result delivery observations. The
ckprompt suite cannot run yet because `validation_command.go` and
`smoke_command.go` still use the legacy reporting API scheduled for Stage 15;
Stage 20 will validate the assembled module.
## Stage 15: Migrate request validation and smoke commands
Status: complete.
Primary files:
[validation_command.go](../../integrations/ckprompt/validation_command.go) and
[smoke_command.go](../../integrations/ckprompt/smoke_command.go).
1. Migrate concrete requirements validation to managed output, passive-message
submission, explicit terminal explanation, and optional cause transport.
Preserve its native report and established status decisions.
2. Migrate each `promptkit test` command and `test all`. Keep the established
four-test order, aggregate request/state reuse, continuation/stop behavior,
cancellation, and operation-error handling except for selected shared
presentation changes.
3. Preserve a substantive smoke report returned alongside an operation error.
Render it before finalization, then attach the terminal explanation. Under
quiet, render into discard while retaining terminal failure visibility.
4. Preserve aggregate collection before its single report is encoded and
submitted. Move aggregate encoding and writing into managed rendering;
do not introduce per-test streaming or change which native tests run because
presentation now uses a reporter. Replace old `smokeDeliveryFailure` flags
with reporter evidence, preserving operation causes and explanations
independently of presentation failures.
5. Remove obsolete manually tracked delivery state. Do not reinterpret native
supported/completed/result fields or add unsolicited warnings.
Validation: use offline stubs/native fixtures to protect aggregate order and
bindings, reports alongside errors, diagnostic ordering, cancellation,
render/write failures, and quiet semantics. Test CommandKit coordination,
not whether PromptKit's validation or smoke checks implement their domain rules.
Concrete request validation and PromptKit smoke commands now submit passive
messages through the reporter and render confirmation, native, and aggregate
reports inside managed output. Smoke reports remain available alongside
operation failures, with native explanations and causes retained separately;
aggregate collection order and shared input/state lifetimes remain covered.
`GOTOOLCHAIN=local go test -count=1 .` passes in `integrations/ckprompt`. The
module-wide `go test ./...` reaches and passes that package but cannot compile
`examples/composed`, whose legacy API callers are scheduled for Stage 17.
## Stage 16: Maintain a core reporting consumer
Status: complete.
Primary files: [examples/core/main.go](../../examples/core/main.go) and its
existing example tests.
1. Migrate current custom commands to `Invocation.Reporter()` while preserving
their existing input and alias demonstrations.
2. Add one small application-owned reporting demonstration to this existing
example, using deterministic local operations/fixtures. Demonstrate ordinary
result rendering, optional Warning and Info, Progress, Debug, and a deferred
summary. Avoid adding another module or an external service.
3. Provide simple selectable scenarios for silent recovery, an explicit warning
with/without a Go error, and a terminal explanation without a cause. Include
failure after partial output and optional cause transport independent of text.
4. Show rendering helpers using their supplied callback writer and additional
message submissions outside callbacks. Keep examples focused on public usage;
delivery accounting mechanics belong in tests/internal documentation.
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: complete.
Primary directories:
[ckconfig example](../../integrations/ckconfig/examples/simple) and
[composed PromptKit example](../../integrations/ckprompt/examples/composed).
1. Migrate custom handlers and provider diagnostics to the selected APIs.
Preserve composition, aliases, configuration scopes, input/state sharing,
and the offline smoke client/fixtures.
2. Ensure examples demonstrate independent provider warnings separately from
native terminal context where applicable. Do not add artificial upstream
capabilities merely to produce every message category.
3. Adapt their existing tests to reporters and presentation observations while
retaining native YAML, discovery, and custom-command behavior protections.
4. Exercise ordinary/quiet configuration output, a verbose provider warning,
aliased help, and a smoke report plus terminal failure using controlled
fixture evidence. Avoid multiplying identical mode cases across all commands.
Validation: both example packages compile in their owning modules and run
without external services. Confirm custom commands and integration commands
share the same reporting flags and terminal behavior.
Completion: migrated both example-owned handlers to invocation reporters and
explicit explanations with optional causes. The ConfigKit consumer demonstrates
a provider schema warning in verbose mode, ordinary report output, and quiet
suppression. The composed consumer retains aliased help and offline smoke usage;
its tests cover a native report with an independent provider warning, terminal
context, and an operation error, plus quiet custom-command success and domain
failure. Both integration modules pass `GOTOOLCHAIN=local go test ./...`,
including both example packages. Representative verbose ConfigKit, quiet
ConfigKit, aliased quiet help, and offline PromptKit smoke commands also pass.
## Stage 18: Reconcile the full test suite and architectural boundaries
Status: complete.
1. Find and migrate remaining callers of removed constructors, raw invocation
streams, terminal `SeverityError`, outcome diagnostic submission, and old
delivery fields across tests, benchmarks, and supporting code. `Streams`
supplied to `Execute` remains valid; passive provider diagnostics remain valid.
2. Preserve regression coverage for alias routing, discovery, installation
atomicity, typed presence/false/zero values, invocation freshness, help without
loading, native report contracts, cancellation, and supported error identity.
Update expectations only where the selected reporting behavior changes them.
3. Consolidate reporting coverage at its owning boundary. Include all selected
delivery states, first-error retention per destination, ignored immediate
failures, deferred/terminal failures, domain status handling, caller-writer
ownership, late handles in quiet, and healthy sinks after renderer failure.
4. Verify reporter operations are safe concurrently and keep complete messages/
callbacks intact. Use coordination primitives rather than sleeps or assumed
scheduling. Do not test recursive reporter callbacks as supported use or
infer concurrent dispatcher support.
5. Extend existing facade/import-boundary checks as needed: all new public types
belong to core, public signatures contain no Cobra/pflag/internal types,
backend imports remain internal, and optional dependencies remain optional.
6. Remove obsolete production helpers only after their responsibilities are
accounted for. Do not retain parallel delivery paths for historical tests.
Validation: the suite should now build as a whole using the local three-module
workspace. Focused reporter race testing is justified by the selected concurrent
reporter contract; it does not establish a new permanent repository-wide gate.
Completion: migrated the remaining core tests and benchmark caller to reporter,
explicit explanation/cause, and presentation-result APIs. Updated reporting
expectations for unprefixed Info, routed flag-alias parsing, quiet selection on
routing failures, and causes remaining separate from terminal explanations.
Reporter tests cover delivery states, retained failures, ignored errors,
finalization, domain statuses, quiet late handles, independent healthy
destinations, complete concurrent operations, and caller-owned writers.
`GOTOOLCHAIN=local go test -count=1 ./...` passes in the root, ckconfig, and
ckprompt modules; `GOTOOLCHAIN=local go test -race -run '^TestReporter' .` passes.
Boundary checks confirm Cobra/pflag imports stay under `internal/cobra`, public
Go documentation exposes no backend or internal types, and core does not depend
on either optional integration module.
## Stage 19: Update canonical documentation and compatibility guidance
Status: complete.
1. Update public Go declarations/GoDoc for exact reporter, outcome, provider,
lifecycle, cause, and presentation-observation contracts. Describe callback
writer scope and the selected non-reentrant callback boundary.
2. Update [CLI reference](../cli.md) with the implemented mode/progress policy,
flag placement and aliases, prefixes/channels, quiet help/version, usage
guidance, and presentation-failure status behavior. Keep full operator
definitions here rather than duplicating them in consumer prose.
3. Update [consumer guide](../consumers/guide.md), [README](../../README.md),
integration package documentation, and [developer guide](../development.md)
as affected. Link to maintained examples; explain migration from raw
invocation streams, error-first constructors, outcome diagnostics, terminal
provider diagnostics, and old delivery fields.
4. Update [internal overview](../internal/overview.md) with implemented ownership,
parse occurrence flow, visibility selection, and finalization. Describe
synchronization as implementation mechanics without expanding contracts.
5. Update the unpublished [core v0.3.0 note](../releases/v0.3.0.md) with reporting
scope and the intentional API/CLI compatibility impact, including newly
reserved flag names. Preserve published historical notes and tags. Integration
release-version selection and publication remain separate release preparation;
do not invent release versions or edit historical integration notes.
6. Mark feature implementation status accurately in the roadmap. Preserve the
accepted ADRs and deferred work; do not create new architectural decisions
for settled API details or retire the planning record during implementation.
Validation: review prose against implemented behavior, check local links and
fragments, and run the Markdown whitespace checks from the developer guide.
Current contract documentation must no longer present the removed APIs as valid.
Completion: updated exported GoDoc, the CLI reference, consumer migration
guidance, README, integration package documentation, developer guide, internal
overview, and the unpublished core v0.3.0 note. Current contracts direct callers
to the invocation reporter and keep operation causes separate from explanations
and presentation observations. Reviewed the prose against the implementation
and selected reporting policy; `go doc` checks, local Markdown link and fragment
checks, and both Markdown whitespace checks pass. Historical integration release
notes, accepted ADRs, and deferred-work records remain unchanged.
## Stage 20: Validate the complete end state and finish
Status: complete.
1. Format changed Go files with `gofmt`; review the complete diff for unintended
behavior changes, unselected restrictions, compatibility machinery, public
backend leakage, or duplicate presentation paths.
2. Preserve the existing ignored local `go.work`. Ensure it includes core,
ckconfig, and ckprompt using the developer guide procedure. Keep unpublished
sibling code under local workspace resolution. Do not commit workspace files,
fabricate dependency sums, or change dependency versions solely to make an
unreleased core API resolve through published versions.
3. Run the ordinary suites for all three modules with `GOTOOLCHAIN=local`, using
the workspace for integration development. The commands below are separate
executions with the indicated working directories:
| Working directory | Command |
| --- | --- |
| Repository root | `GOTOOLCHAIN=local go test ./...` |
| `integrations/ckconfig` | `GOTOOLCHAIN=local go test ./...` |
| `integrations/ckprompt` | `GOTOOLCHAIN=local go test ./...` |
| Repository root | `GOWORK=off GOTOOLCHAIN=local go test ./...` |
4. Run focused core reporter tests with the race detector, choosing `-run` to
match the tests created during the sprint. Exercise maintained example
commands covering core result/messaging, quiet help/version, scoped config
YAML, aliases, and offline PromptKit report-plus-failure presentation.
5. Treat workspace-based integration validation accurately: it validates this
sprint's local modules. Independent integration dependency graphs are updated
and validated during release preparation after their new dependencies are
published, following the existing core → ckconfig → ckprompt release order.
No release-tool publication exercise, remote push, or new toolchain/dependency
upgrade is part of this feature validation.
6. Check that every selected roadmap behavior is implemented or has a clearly
identified blocker. Resolve sprint defects before declaring completion.
Keep incidental debug wording, concurrent scheduling order, and private file
layout out of contractual assertions.
7. Review staged/untracked contents, local links, and `git diff --check` plus
`git diff --cached --check`. Finish with focused local commits and report
validation results, any genuine limitations, and worktree status. Do not
declare a release or remove the roadmap/plan automatically.
Completion: `gofmt` left the changed Go files formatted. The root, ckconfig, and
ckprompt workspace suites pass; the root workspace-off suite and focused
reporter race suite pass. Maintained examples pass for core reporting, quiet
help/version, scoped PromptKit YAML, and aliases. The composed-example failure
test confirms an offline smoke report remains available alongside an injected
operational error and terminal context. The ignored `go.work` still includes
all three modules and was not committed. Final Markdown links and fragments and
staged and unstaged whitespace checks pass. All selected roadmap behaviors are
implemented; no release or publication action was performed.
## Completion criteria
The sprint is complete when the selected public API replaces the old paths;
normal parsing supplies reporting selection in every context; one reporter owns
each invocation's presentation and finalization; core and optional integrations
share that policy; native upstream evidence remains authoritative; maintained
examples and current documentation use the implemented API; and complete local
validation succeeds. No additional product decisions are required for this plan.

523
docs/roadmap/remediation.md Normal file
View File

@@ -0,0 +1,523 @@
# Invocation reporting audit remediation
Status: remediation complete; all 7 findings addressed.
## Review scope
Reviewed the reporting implementation from `2a1096b` through `7c035cc` against
the [feature roadmap](reporting.md), [implementation plan](implementation.md),
[ADR 0014](../adr/0014-invocation-reporting.md),
[ADR 0005](../adr/0005-invocation-outcomes.md), and the
[architecture policy](../policy/architecture.md).
The intended ownership is substantially implemented: core owns invocation
reporting, ordered presentation selection, coordinated delivery, and finalization;
the internal Cobra adapter supplies neutral parsing evidence; optional
integrations own native adaptation and command presentation. Providers remain
passive, and ckprompt depends on ckconfig without a reverse dependency.
The findings below concern missing presentation and error evidence within that
architecture. They do not require a different public architecture or automatic
rendering of application causes. No separate unrequested registration restriction,
concurrent-dispatch guarantee, or upstream-domain responsibility was identified.
Evidence includes source and test review, maintained example invocations, and
temporary public-API audit consumers outside the repository. The audit did not
modify implementation code or add tests, and did not rerun the full test suites.
The follow-up interaction audit at `1e532cd` reviewed reporting against existing
alias contexts, typed bindings, scope/input/state preparation, cancellation,
native reports, and maintained consumers. Representative successful invocations
covered command and scoped flag aliases with explicit zero and repeated values,
quiet help/version, aggregate configuration YAML, quiet comparison, concrete
request validation, and offline aggregate smoke testing. Findings 4 and 5 record
the additional gaps; findings 1–3 were still open at that review and were not
duplicated below.
The algorithmic audit at `ee76b0f` examined the reporting-specific parser passes,
delivery bookkeeping, deferred storage, native diagnostic partitioning, and
integration report coordination. It used source-level complexity analysis, not
benchmarks or real-world performance measurements. Selected algorithms were
excluded from reconsideration, including synchronous serialized reporting, FIFO
deferred delivery, rendering rich content under quiet mode, fresh backend trees,
and the chosen configuration/smoke report preparation boundaries. Findings 6 and
7 are implementation opportunities within those decisions.
No newly introduced quadratic-time algorithm was identified in the reviewed
paths. In particular:
- Reporting selection is linear in the number of flag occurrences. There are
four fixed reporting flags and at most two additional reverse searches for
selection provenance; these do not create a quadratic pass.
- Deferred insertion is amortized constant time plus callback-list copying;
draining is linear in queued messages and details, excluding callback/I/O
costs. The avoidable storage and handoff costs are described in finding 6.
- Native diagnostic snapshotting and partitioning are linear in source records,
diagnostic records, and the total related-location entries. Nested copying of
related locations is not quadratic in the actual input size.
- Explanation combination uses amortized slice appends. Configuration comparison
has two sides, and aggregate smoke execution has four selected operations;
neither introduces an unbounded nested traversal through all prior results.
The algorithmic audit found no additional ordinary correctness issue beyond
findings 1–5. It made no implementation changes and ran no tests or benchmarks.
## 1. Preserve native PromptKit operation failure explanations
Priority: P2. Status: complete.
- [x] Completed: bundled native operation failures now supply terminal explanations
without printing arbitrary application causes. Explicit provider context and
independent warnings retain their presentation paths. Native-error forwarding
and single-presentation regressions pass in ordinary and quiet modes.
Validation: `GOTOOLCHAIN=local go test ./...` in `integrations/ckprompt`.
### Finding and evidence
[WithRequestValidation and FromEngine](../../integrations/ckprompt/adapters.go)
carry native operation errors back to their callers, but their acquired terminal
explanations contain only configuration-loading diagnostic records.
[runRequestValidation](../../integrations/ckprompt/validation_command.go) and
[definitionFailure](../../integrations/ckprompt/command.go) then retain the native
error solely as `Cause` and present a generic operation heading when no acquired
explanation exists. Native request-validation and lookup failures consequently
lose their useful explanation at the CLI boundary.
Reproduced in the maintained composed example:
```sh
go run . promptkit requirements validate briefing --campaign spring
```
Its entire diagnostic is:
```text
ERROR: Unable to validate the PromptKit request for prompt "briefing".
```
A separate public-API consumer using the bundled validation adapter confirmed
that the native error contains `missing required input artifact: topic`, while
the acquisition has neither an explanation heading nor a detail renderer. The
cause survives, but the operator cannot see the missing input. The maintained
`promptkit show prompt missing --campaign spring --quiet` invocation likewise
omits the native lookup explanation.
This falls short of useful access to native capabilities and the roadmap's
requirement to keep failures understandable. Separating causes from presentation
does not prohibit an integration from deliberately adapting native failure
content into an explanation. Existing native-failure tests primarily check
classification and cause identity while discarding the diagnostic destination,
so they do not protect this handoff.
### Required work
- At the bundled PromptKit adapter/command boundary, deliberately provide the
relevant native operation failure explanation, using the owning API's public
presentation and disclosure rules. Cover validation and the other native
inspection/selection paths affected by the same migration.
- Preserve the original cause independently and preserve an explicitly supplied
provider explanation. Keep independent warnings separate and avoid presenting
the same native failure through two paths.
- Keep core's cause-neutral behavior. Do not solve this by printing arbitrary
`Outcome.Cause` values or treating every application-owned loader/provider
error as safe operator text.
### Acceptance evidence
Use controlled upstream failure evidence to verify that CommandKit forwards
useful native failure content into the terminal explanation in ordinary and
quiet modes, with cause identity retained and unrelated warnings still hidden
where selected. Check explicit provider explanations for single presentation.
These are adapter/presentation assertions, not tests of whether PromptKit
correctly detects missing artifacts or invalid selections.
## 2. Retain independent rendering errors joined with write errors
Priority: P2. Status: complete.
- [x] Completed: joined callback errors retain independent rendering evidence,
while plain and wrapped sink errors remain write failures. Output and detail
regressions pass, including joined destination errors.
Validation: `GOTOOLCHAIN=local go test ./...` in the repository root.
### Finding and evidence
In [reporter.go](../../reporter.go), `recordRendererErrorLocked` drops the entire
callback error whenever `errors.Is(renderErr, writeErr)` is true. That test also
matches `errors.Join(independentRenderErr, writeErr)`: the callback error can
contain an independent rendering failure as well as the propagated sink failure.
The shared helper serves `RenderOutput`, message details, and terminal details.
Dropping the whole joined error loses evidence both from the immediate returned
error and from `Presentation.*.RenderError`.
A public-API audit consumer used a failing result writer and this callback:
```go
_, writeErr := io.WriteString(w, "result")
return errors.Join(renderErr, writeErr)
```
The invocation failed as expected and retained the sink error, but
`errors.Is(returnedError, renderErr)` was false and the final output `RenderError`
was nil. This contradicts the selected retention of independently returned
rendering failures. The current separation test covers returning a standalone
rendering error after a failed write, but not a callback that already joins them.
### Required work
- Distinguish a callback merely propagating its managed-writer failure from a
callback carrying additional independent error evidence. Do not discard the
entire returned error just because its chain also contains the sink error.
- Preserve the independent callback error in the immediate error result and
bounded rendering observations. Retain the first write error separately and
continue avoiding further writes to the failed destination.
- Keep ordinary propagation of a write error from being misclassified as an
independently discovered rendering failure. No transcript or new public
error-classification API is needed.
### Acceptance evidence
Add a focused joined-error regression at the shared reporting boundary. Verify
`errors.Is` access to both failures in the returned error, rendering evidence in
`RenderError`, and sink evidence in `WriteError`. Preserve existing coverage for
plain/wrapped write-error propagation and first-error retention. Exercise a
detail-rendering path as needed to establish that the shared fix applies there.
## 3. Explain failures introduced by CommandKit presentation
Priority: P2. Status: complete.
- [x] Completed: presentation failures that turn success into failure now retain
a core-owned rendering/delivery explanation. Supplied explanations and causes,
generic handler fallback, quiet rendering, domain status, and failed-sink
behavior remain covered. Validation: `GOTOOLCHAIN=local go test ./...` at root.
### Finding and evidence
[Reporter.finalize](../../reporter.go) changes an otherwise successful outcome
to failure when presentation failed, then invokes the same
`ensureFallbackExplanation` used for an unexplained failed handler outcome.
Consequently, a CommandKit-invoked renderer failure or a result-write failure
produces only:
```text
ERROR: Command failed.
```
The diagnostic destination may be entirely healthy. The caller can inspect
`Presentation`, but the operator is not told that rendering or delivery failed.
This also affects successful domain outcomes changed to non-zero status.
Public-API consumers reproduced the generic explanation after both a callback
that failed before writing and a rejected result write. The corresponding
reporter and execution tests currently assert this generic text, so they preserve
the gap rather than the selected distinction.
[Stage 4](implementation.md#stage-4-implement-deferred-delivery-and-finalization)
requires a useful core-owned explanation when presentation turns success into
failure. The roadmap assigns handling of reporting failures to CommandKit and
distinguishes healthy diagnostic destinations from failed result destinations.
The generic fallback remains appropriate for a failed handler outcome that
supplies no explanation; it does not explain the presentation failure core
itself discovered.
### Required work
- When presentation changes a successful outcome into failure and no explanation
was supplied, attach and retain an explanation identifying the failed
presentation operation, such as result rendering or result delivery. Present
it through a healthy diagnostic destination.
- Keep the original optional cause unchanged and preserve supplied explanations.
An informative core-owned heading is sufficient; this does not require
automatically exposing callback error strings or application causes.
- Preserve the selected outcome/status table, exact generic fallback for
otherwise unexplained failed outcomes, and explicit native explanations,
including a non-nil renderer that emits no bytes.
- Do not retry a failed diagnostic destination, redirect into substantive output,
replay explanation callbacks, or introduce recursive reporting-failure messages.
### Acceptance evidence
Update focused tests so a successful operation with an output-rendering or
output-writing failure receives a retained presentation-specific explanation
through working diagnostics, including quiet rendering failure and a successful
domain outcome. Preserve evidence for supplied explanations/causes, generic
fallback on unexplained handler failure, and diagnostic-sink failure without
retry. Assert the meaningful failure category rather than inventing a new
public exact-wording guarantee.
## 4. Restore cancellation checks between report preparation and delivery
Priority: P2. Status: complete.
- [x] Completed: restored preparation-before-delivery cancellation checks for
PromptKit catalogs, definitions, requirements, and validation, and buffered
configuration diff/sources/YAML explanation reports. Cancellation retains its
operation cause without becoming a rendering failure; native streaming and
smoke-report behavior remain unchanged. Controlled preparation/serialization
checks cover ordinary and quiet modes. Validation: module suites pass in
`integrations/ckconfig` and `integrations/ckprompt`.
### Finding and evidence
Before the reporting migration, PromptKit's `writePromptReport` checked the
invocation context after a report had been prepared and before writing it.
The migrated [renderProfileReport](../../integrations/ckprompt/command.go)
formats its text report inside `RenderOutput`, writes it immediately, and
returns success without that intervening cancellation check. The surrounding
inspection command checks cancellation only before entering report preparation.
A deterministic public-API consumer supplied a profile inspection with a
controlled YAML marshaler in `EffectiveModelParams.ExtraParams`. The marshaler
cancelled the invocation context and returned a valid value while the text
report was being prepared. This uses the ordinary report serialization boundary;
it does not reenter the reporter or require concurrency or an external service.
Observed results for `promptkit show profile demo --format text`:
| Mode | Context at completion | Status | Cause matches cancellation | Report bytes |
| --- | --- | --- | --- | --- |
| Ordinary | `context.Canceled` | `0` | No | `277` |
| Quiet | `context.Canceled` | `0` | No | `0` |
Cancellation occurred before the first destination write. Previously, the shared
post-preparation check would have returned operational failure with the
cancellation cause and left the report destination untouched. The migration also
removed similar post-preparation checks from buffered configuration report paths,
including [config diff](../../integrations/ckconfig/diff.go). Its current
post-render cancellation branch runs only when rendering already returned an
error, so successful preparation does not preserve the old checkpoint.
The plan requires preserving existing cancellation behavior outside the selected
reporting changes. Quiet suppression is not intended to turn this cancellation
into success. Existing cancellation tests cover acquisition and write failures,
but do not cover cancellation during successful report preparation.
### Required work
- Restore the integration-owned cancellation checkpoint after report preparation
and before delivery in migrated paths that previously had it. Review the
removed `writePromptReport` callers and the buffered configuration paths rather
than patching only the demonstrated profile-text case.
- Preserve supported cancellation identity in the operation outcome, while
retaining reporter ownership of rendering and destination failures. Apply the
same operation decision under quiet mode.
- Keep this change bounded to the existing preparation/delivery behavior. Do not
introduce a core-wide rule that rejects every cancelled application invocation,
interrupt caller writers, withdraw delivered bytes, or promise cancellation
after delivery has already begun. New whole-run buffering is unnecessary.
### Acceptance evidence
Use a controlled serialization/preparation callback to cancel before the first
report write. Assert operational failure, `errors.Is` access to
`context.Canceled`, and no report delivery, including quiet mode. This verifies
CommandKit coordination, not upstream serialization or domain behavior. Preserve
existing coverage for cancellation during acquisition, partial output, and
caller-owned writes that have already started.
## 5. Apply reporting-flag aliases in unresolved-route parse contexts
Priority: P2. Status: complete.
- [x] Completed: unresolved routes obtain aliases from the context selected by
normal parsing, through neutral internal metadata. Root and nested-group
canonical/alias comparisons, quiet failure visibility, scoped precedence,
unchanged unresolved identity, and factory non-execution pass.
Validation: `GOTOOLCHAIN=local go test ./...` at the repository root.
### Finding and evidence
[compileFlagAliases](../../flag_aliases.go) now resolves application-wide aliases
to the new reporting flags in root and group contexts. However,
[execution.prepareSession](../../execute.go) supplies aliases to the parser only
for `routeCanonical` and `routeDiscovery`. For an unresolved command path it
supplies none, even when the normal parser selects the root or a known group
whose reporting flags and alias mappings are available.
With an installed application-wide `FlagAlias("-d", "--debug")`, a public-API
consumer reproduced:
| Invocation | Observed diagnostics |
| --- | --- |
| `app missing --debug` | Debug route/selection detail, then the unknown-command usage failure. |
| `app missing -d` | `unknown shorthand flag: 'd' in -d`, with no Debug detail. |
| `app group missing --debug` | Debug detail, then the group operand/command usage failure. |
| `app group missing -d` | `unknown shorthand flag: 'd' in -d`, with no Debug detail. |
| `app group run -d` | Successful command execution with Debug detail. |
The spelling registered for a reporting flag is thus treated as unknown on the
failure paths where operators may need debugging. Canonical mode selection on
these paths already works. This conflicts with the
[CLI alias contract](../cli.md#application-owned-aliases): an application-wide
alias is effective wherever its canonical target is available. The new reporting
flags make these existing parse-context gaps observable for mode selection.
Current reporting parse tests cover a canonical reporting flag after a missing
command and aliases on valid routes, but not their combination.
### Required work
- Supply the alias mapping applicable to the context used by normal parsing
when the full entered command path does not resolve. Cover root and known
group parse contexts, preserving existing scope/precedence rules where they
apply.
- Keep ordered canonical/alias occurrence handling in the normal parser. Do not
add a separate reporting-flag scanner or enable unrelated aliases whose
canonical target is unavailable.
- Preserve the original routing failure, handler/factory non-execution, and
entered-path usage behavior. Finding a parse context must not manufacture a
successfully resolved canonical command identity for the invalid route.
### Acceptance evidence
Compare canonical and application-wide aliased reporting flags on unresolved
root and nested-group paths. They should select the same mode and retain the
same underlying command-path failure rather than invent an unknown-flag error.
Include a quiet alias followed by another parse error, without suppressing the
terminal usage explanation. Retain scoped-alias precedence, unavailable-target,
terminator/value handling, and discovery-context coverage at their existing
owners.
## 6. Avoid retaining deferred messages that need no presentation work
Priority: P3. Status: complete.
- [x] Completed: renderer-free hidden deferred messages record suppression
without queue retention. Finalization detaches the queue without an array
copy and delivers captured callback lists without recopying them. Hidden rich
callbacks, FIFO order, no-content handling, capture, and closure remain covered.
Validation: root module suite and focused reporter race checks pass.
### Finding and complexity
[DeferredReporter.enqueue](../../reporter.go) unconditionally stores the category,
heading, and copied detail list. Visibility is checked only when `finalize`
drains the queue through `submitMessage`. Consequently, a long-running handler
that submits many simple deferred Debug messages in ordinary mode, or simple
Info/Warning messages in quiet mode, retains all of them despite there being
no output or renderer work to perform.
Let `M` be the number of these renderer-free hidden messages and `B` the bytes
of otherwise unreferenced distinct heading text. Reporter storage can grow as
`Θ(M + B)` until finalization. Finalization then makes a second `Θ(M)` queue
array copy before visiting and suppressing every entry. This is linear, not
quadratic, but the additional reporter storage for this case can be `O(1)`.
The `M` public submissions still require `Θ(M)` total call work; this is not a
claim that the whole invocation can become sublinear.
### Refactoring opportunity
Under the current execution flow, reporting selection is applied before handlers
and providers run, with no public operation to change it mid-handler. For a
hidden deferred submission with no non-nil detail callback, consider recording
the eventual suppression observation without retaining the heading or queue
entry. Preserve the existing no-content treatment of an empty heading with no
renderer. This removes the avoidable `Θ(M + B)` retention and its later sweep
for renderer-free suppressed messages.
Retain rich hidden submissions: their callbacks must still execute at the
selected deferred time against discard so rendering failures are preserved.
Retain visible messages in FIFO order. Closed-reporter checks must still run
before accepting or discarding any submission. This optimization does not add
a public immutability guarantee for future mode APIs or change the selected
emission-time visibility rules.
Independently, `finalize` can detach the reporter-owned queue under its existing
mutex instead of copying its array and then clearing the original reference.
That makes queue handoff `O(1)` instead of `Θ(M)` extra copying; actual visible/
rich message delivery remains linear. An internal delivery path can also avoid
recopying callback lists already captured by `enqueue`, while preserving the
selected defensive copy when first accepting caller-owned lists.
These opportunities preserve the chosen queue and synchronous delivery model.
They do not justify message caps, dropping visible messages, skipping rich
renderers, or a background delivery subsystem.
### Review evidence
The retention and copies follow directly from `enqueue`, the array copy at the
start of `finalize`, and `submitMessage`'s later visibility check. If selected,
use focused behavior checks for suppression observations, FIFO delivery,
callback capture, hidden renderer failures, and late submissions. Source-level
storage analysis is sufficient; no benchmark or allocation threshold is proposed.
## 7. Stop normalizing an argument tail that parsing cannot reach
Priority: P3. Status: complete.
- [x] Completed: interpretation returns the first fatal unknown-flag prefix,
retaining earlier canonical/alias occurrences and short-cluster members.
Temporary slices grow with that prefix; unreachable values and spellings are
no longer normalized or copied. Existing error precedence, values, and
terminator coverage passes. Validation: root module suite passes.
### Finding and complexity
In [interpretFlagTokens](../../internal/cobra/flags.go), the first unknown long
flag or unknown short-cluster member sets `recordingOccurrences = false`, but
interpretation continues through the remaining argv. Later recognized flags
still populate spelling lists and construct canonical `--name=value` strings.
[Session.Parse](../../internal/cobra/parse.go) then calls the backend, which
fails at the earlier unknown flag without consuming that normalized tail.
The adapter does not enable unknown-flag whitelisting.
For example, after `--unknown`, a later valid `--message=LARGE_VALUE` still
causes the value bytes to be copied into a new canonical argument, even though
no parser, converter, or handler reaches that value. The recent occurrence
tracking correctly excludes the tail from selection evidence but retains this
pre-existing unnecessary normalization work.
Let `F` and `A` be the flag/alias table sizes, `P` the token/byte processing cost
through the first fatal unknown flag, and `T` the remaining normalization work,
including copied value bytes. With expected constant-time map lookups, the helper
currently takes `O(F + A + P + T)` time on that failure path. It can take
`O(F + A + P)` by stopping at the failing token and growing temporary arrays
with the prefix instead of preallocating for all argv. The existing backend can
still produce the same failure from that prefix. This is an early-failure
improvement, not a quadratic-time finding or a change to the successful parse's
necessary linear work.
### Refactoring opportunity
Consider ending interpretation immediately after preserving the first unknown
flag or failing short-cluster suffix. Preserve any earlier valid occurrences and
their spellings, including recognized members preceding a bad short member.
Let the backend retain its existing failure classification, wording, and
precedence; do not replace normal parsing with a reporting-specific scanner.
This can also remove the repeated `recordingOccurrences` guards needed solely
because the current implementation continues past the failure.
The helper currently preallocates both output and occurrence slices from total
argv length, so an early return alone does not eliminate its `O(argc)` upfront
array allocation and initialization. To obtain the prefix-bounded helper cost,
grow those arrays with the interpreted prefix rather than reserving for the
unreachable tail. This changes allocation strategy, not accepted input sizes.
Other routing/parser steps still copy argv string headers, so this refactoring
does not make the entire invocation prefix-linear. It specifically removes
avoidable tail interpretation, spelling storage, and large-value byte copies.
There is no need to reconsider the selected backend or fresh-tree strategy.
### Review evidence
The unknown-flag branches and continued spelling/string construction establish
the extra work statically. Existing prefix-order, missing-value precedence,
short-cluster, and terminator/value tests provide relevant behavior coverage if
this is selected. Preserve the backend error and consumed occurrence prefix on
early failure; no timing threshold or real-world performance data is needed.
## Final remediation validation
All seven items have completion markers and focused regression evidence. The
following checks pass for the assembled changes:
- `GOTOOLCHAIN=local go test ./...` at the repository root and in each of
`integrations/ckconfig` and `integrations/ckprompt`, including maintained examples.
- `GOWORK=off GOTOOLCHAIN=local go test ./...` at the repository root.
- `GOTOOLCHAIN=local go test -race -run '^TestReporter|^TestHidden' .` at root.
- Go formatting, staged/unstaged whitespace review, and local document-link review.
The ignored local workspace is preserved. No dependency upgrade, release,
publication, or change to upstream domain behavior was part of this work.

890
docs/roadmap/reporting.md Normal file
View File

@@ -0,0 +1,890 @@
# Output policy and invocation reporting
Status: behavioral decisions selected through OD-4. Message categories, all four
output modes, uniform treatment of help and version, message prefixes, and
OD-1's flag grammar and scope (including independent Progress control) are selected.
OD-2's unified reporting direction, the terminal failure model, and application
ownership of domain-specific error decisions are selected.
OD-3's failure presentation and retention boundaries are selected.
OD-4's delivery lifecycle, write-failure handling, and quiet-mode rendering are
selected. The consumer API signatures, presentation-result model, provider
diagnostic boundary, fallback explanations, and rendering callback boundary
below are also selected. Architectural preparation is complete:
[ADR 0014](../adr/0014-invocation-reporting.md) is accepted, and the
[architecture policy](../policy/architecture.md) is aligned. Implementation and
final validation are complete; Stages 1–20 are complete. The [implementation
plan](implementation.md) records the stage order and validation evidence.
## Purpose
Provide consistent handling of `--quiet`, `--verbose`, `--debug`, and
`--progress` across application commands and optional integrations. Reduce
repeated output classification, diagnostic presentation, and delivery bookkeeping
where CommandKit can provide a meaningful benefit.
Ordinary Go writers and formatting functions already make application output
straightforward. Design reporting conveniences around the shared output policy
and invocation lifecycle. Applications continue to own their report content,
domain behavior, and operation outcomes.
## CLI presentation and error-handling ownership
Applications decide how to respond to domain-specific errors: recover, retry,
continue silently, report a Warning, or end the invocation with a failure.
CommandKit may provide shared helpers for carrying, inspecting, and presenting
errors while leaving those decisions with the application. A Go `error` alone
does not determine a CLI message category. Warnings may describe issues with or
without an underlying Go error, and handling an error does not require emitting
a message.
CommandKit handles the presentation the application requests: message category,
visibility, formatting, timing, and delivery. For terminal application failures,
the application supplies a non-zero outcome and an operator-facing explanation.
It may also supply an optional Go error cause for the execution caller to inspect.
CommandKit preserves that cause and its existing `errors.Is`/`errors.As` behavior.
Supplying a cause alone does not select an outcome, generate an operator-facing
message, or change visibility.
Applications can explicitly present `err.Error()` as a Warning through the
ordinary message API, illustrated by `report.Warn(err.Error())`. Defer a separate
`WarnError` convenience helper pending demonstrated value; this conversion
already requires little code.
CommandKit retains responsibility for handling its own errors, including
routing, parsing, and reporting failures. Integration-owned commands similarly
adapt their upstream APIs into deliberate command outcomes and presentations.
Ordinary Go error returns from CommandKit APIs and writers remain available where
appropriate.
## Current baseline
[`Streams` and `Invocation`](../../identity.go) expose caller-selected output
and diagnostic writers. [`Outcome` and `Diagnostic`](../../outcome.go) carry
operation causes, diagnostic writers, and delivery state. Handlers can return
`Failure(err)` or attach diagnostics to an outcome for presentation after the
handler returns. `MessageDiagnostic` currently writes the supplied message with
a newline; severity does not add a label automatically.
CommandKit coordinates final diagnostic delivery and retains delivery failures
separately from the operation cause. Custom handlers can also write directly to
the supplied streams and retain responsibility for their output. The existing
outcome and channel ownership decisions are recorded in
[ADR 0005](../adr/0005-invocation-outcomes.md).
Integration adapters already translate upstream diagnostic records into
CommandKit diagnostics while preserving native rendering and source context.
Some adapters wrap multiple records in one diagnostic writer and classify the
batch by its highest severity. This matters when designing selective visibility.
There is currently no shared verbosity policy or invocation reporter API.
## Selected output policy
Distinguish substantive application output from diagnostic message categories:
- **Substantive output (Normal):** The requested report or command result.
- **Error:** Terminal presentation of an unrecoverable invocation failure,
resulting in a non-zero status code.
- **Warning:** Something went wrong or deserves attention, but the invocation
can still succeed. The application chooses whether to report such an issue.
- **Info:** Informational context and useful explanation, such as which profile
was selected or a final operation summary.
- **Progress:** Streaming updates about work underway, such as items processed.
- **Debug:** Troubleshooting detail, including intermediate decisions and
execution-path information.
Debug mode adds troubleshooting detail to verbose output:
| Output category | `--quiet` | No flag | `--verbose` | `--debug` |
| --- | --- | --- | --- | --- |
| Substantive application output | Suppressed | Shown | Shown | Shown |
| Error | Shown | Shown | Shown | Shown |
| Warning | Suppressed | Suppressed | Shown | Shown |
| Info | Suppressed | Suppressed | Shown | Shown |
| Debug | Suppressed | Suppressed | Suppressed | Shown |
| Progress, without an explicit setting | Suppressed | Suppressed | Suppressed | Shown |
Info belongs in verbose mode; debug includes verbose messages. Normal output
remains a substantive result category, separate from diagnostic severity.
The category of a message determines its visibility; stream destination alone
cannot distinguish a warning from an error.
Progress is distinct from Info and is controlled separately as specified below.
Applications and integrations are not required to generate messages in any
category. Enabling a category adds no output when there are no messages in it;
other output remains subject to its own visibility rules.
Error presentation follows the terminal failure model below. An application
may report a recovered problem as a Warning or continue without a message.
Apply the same policy to help and version. There are no command-specific
exceptions for successful output:
```text
app help --quiet # succeeds silently
app version --quiet # succeeds silently
```
Usage and operational failures still report their errors in quiet mode.
The feature must distinguish successful output suppression from failure
diagnostic delivery.
### Message prefixes and channels
Substantive output uses the result destination, conventionally stdout. Warning,
Info, Progress, Debug, and terminal explanations use the diagnostic destination,
conventionally stderr. Callers supply both destinations.
Use `WARN:`, `DEBUG:`, and `ERROR:` prefixes for simple warning, debug, and error
messages respectively. Substantive output and Info messages have no prefix.
For multiline diagnostics, prefix the opening line and preserve the structured
detail. Integration adapters coordinate native diagnostic rendering to avoid
duplicate severity labels or loss of upstream formatting.
Usage guidance belongs to the terminal usage explanation and remains visible in
quiet mode. Successful help output follows the substantive-output policy.
Initially, Progress messages are unprefixed streamed lines. The diagnostic
destination keeps them separate from substantive output such as JSON or YAML.
Terminal redraw and progress-bar behavior are outside this implementation.
### Terminal failure model
During execution, applications can produce substantive output, Progress, Info,
Debug, and Warnings. An unrecoverable failure ends the handler by returning a
failed outcome. There is no standalone `report.Error(...)` method. Detailed
error explanations accompany the returned failure and are presented as part of
that terminal step.
CommandKit finalizes reporting, presents the terminal failure explanation, and
returns the non-zero outcome to the application entry point. It accepts no
further normal output for that invocation after terminal error presentation.
The same boundary applies to core routing and parsing failures that prevent
handler execution. The application entry point retains ownership of process
exit; CommandKit ends the invocation and returns its outcome.
For example, if an export operation fails and the application decides it cannot
continue, the handler returns a non-zero outcome with an explicit explanation
such as "Unable to export the requested report." The application can choose to
include detail from a Go error in that explanation. CommandKit presents the
supplied explanation without automatically deriving text from an application
error. The selected API below provides failure constructors for this purpose.
When a failed outcome supplies no explanation, CommandKit supplies the generic
fallback defined under terminal outcomes below.
Output streamed before the failure remains visible according to its selected
mode; terminal failure does not withdraw earlier bytes. Deferred messages,
cleanup writes, and delivery failures must respect the end of normal reporting.
Their finalization and late-write behavior follow OD-4 below. A terminal
failure may have multiple explanatory records, all belonging to that failure.
### Failure explanations and presentation ownership (OD-3)
Make a non-zero outcome with an explicit message the convenient failure path.
Support structured explanations, including multiple records, through the same
presentation facility for richer application and integration reports. Neither
form requires an underlying Go error.
The application or integration explicitly identifies the context belonging to
a terminal explanation. Present that context with the failure in every mode,
including quiet. Independent warnings and other messages retain their own
visibility rules. For mixed upstream batches, adapters preserve useful native
rendering while distinguishing terminal explanatory context from independent
messages. Core does not infer those relationships from error chains or text.
Assign each submitted message one presentation path. Returning a terminal
explanation schedules its presentation once; core does not append another
message derived from an application error cause. Native renderers can supply
already-labelled content without receiving duplicate prefixes. Avoid text-based
deduplication: separate events may legitimately produce identical messages.
Constructing a failure result attaches explanatory content without emitting or
independently scheduling an Error. Returning that result hands the explanation
to CommandKit for terminal delivery through the invocation's reporting facility.
The selected API expresses this distinction for both simple and structured
explanations. For example:
| Handler action | Presentation |
| --- | --- |
| Construct a non-zero result with "Unable to export the requested report," then return it. | Construction emits nothing. CommandKit presents the message once during terminal delivery. |
| Return a failure with native diagnostic records and their source context, submitting independent warnings separately. | CommandKit presents the structured terminal explanation in every mode. Independent warnings follow their own visibility policy. |
### Retention boundary (OD-3)
CommandKit provides invocation-scoped CLI presentation. Logging and message
history collection remain application concerns. Keep terminal explanations and
any supplied cause with the returned outcome, and keep the temporary state needed
for deferred delivery, parser events awaiting mode selection, and delivery
bookkeeping. The reporting facility does not collect a transcript or retain other
emitted or suppressed messages for later inspection. No optional message-history
facility is planned for this implementation.
## Selected flag grammar and scope
### Mode selection and resets
Model quiet, ordinary, verbose, and debug as mutually exclusive presentation
modes. Each invocation starts in ordinary mode. Process flag occurrences in
invocation order; the last enabled mode wins. Repetition does not increment
verbosity, and debug is selected explicitly.
Accept presence and the existing explicit boolean syntax, including
`--quiet=true` and `--quiet=false`. A false occurrence clears its mode only if
that mode is currently selected, returning to ordinary mode. It does not restore
an earlier selection or disable a different mode. Every occurrence is converted;
invalid boolean values remain usage errors even when a later flag would replace
the selection.
| Flag sequence | Final mode |
| --- | --- |
| `--quiet --verbose` | Verbose |
| `--debug --quiet` | Quiet |
| `--verbose --verbose` | Verbose |
| `--verbose --verbose=false` | Ordinary |
| `--quiet --verbose=false` | Quiet |
| `--verbose --quiet --quiet=false` | Ordinary |
| `--quiet=false --debug` | Debug |
Keep verbosity a presentation choice: quiet preserves the requested operation's
semantics and rendering failures. Delivery failures depend on the output actually
requested; suppressing a destination write can avoid a delivery failure. Apply
the policy through CommandKit-managed reporting and streams. Output written
directly to process streams or external destinations remains application-owned.
### Progress selection
`--progress` enables Progress without enabling Info, Warning, or Debug messages.
`--progress=false` explicitly disables it. Accept the existing boolean syntax;
repeated canonical or aliased progress settings use the last value. Retain
whether a setting was explicitly supplied, separately from its boolean value.
Determine effective Progress visibility from the final presentation mode and
explicit progress setting:
1. Quiet mode always suppresses Progress.
2. Otherwise, an explicit progress setting controls visibility.
3. Without an explicit setting, debug enables Progress; ordinary and verbose
modes leave it disabled.
An explicit progress setting overrides the mode's default regardless of its
position relative to mode flags. It does not select or reset the presentation
mode. Debug includes verbose detail and defaults Progress on; verbose alone
does not enable Progress.
| Flags | Result |
| --- | --- |
| `--verbose` | Warning and Info, without Progress. |
| `--verbose --progress` | Warning, Info, and Progress. |
| `--debug` | Warning, Info, Debug, and Progress. |
| `--debug --progress=false` or `--progress=false --debug` | Debug detail without Progress. |
| `--quiet --progress` or `--progress --quiet` | Only failure diagnostics. |
| `--debug --verbose` | Verbose detail without Progress, absent an explicit progress setting. |
Passing `--progress` to an application that emits no Progress messages adds no
visible output. It does not suppress that application's substantive results or
require a progress implementation.
### Placement, availability, and aliases
Core supplies `--quiet`, `--verbose`, `--debug`, and `--progress` for application
commands, integrations, the root, canonical groups, and alias-only discovery help.
Consumers do not register them individually. Preserve the existing placement
grammar: flags follow the complete command path and may be interspersed with
operands after that path. For example:
```text
app config show --quiet
app export destination --verbose
app help config show --quiet
app version --quiet
```
Options before or between command-path components are outside this grammar.
Tokens consumed as values and tokens following `--` retain their ordinary
meaning.
Reserve the four long spellings. Applications may add `-q`, `-v`, or `-d`
through the alias API; no built-in short spellings are selected. An application
flag declaration that defines a reserved long name fails installation with a
collision error. Aliases may target these flags but may not overload canonical
spellings. Apply the existing collision and atomic installation rules, including
later registration.
Aliased and canonical occurrences participate in the same ordering. With
`-q → --quiet`, `app export --verbose -q` selects quiet mode. Document the newly
reserved canonical names as a compatibility change.
### Routing and parsing failures
Determine the mode through the normal parser before execution or successful
help/report presentation. Do not add a separate verbosity scanner. This keeps
token interpretation consistent and avoids reinterpreting values or operands.
Routing and parsing failures report their errors regardless of verbosity. For
example, `app export --quiet --unknown` still reports the unknown flag. Error
visibility follows the selected output policy independently of how the mode is
determined; avoiding a separate scanner is an implementation simplification.
## CommandKit's own diagnostics
Use the shared reporting system for CommandKit's invocation diagnostics so
parser, integration, and application messages follow the same visibility and
delivery policy. Installation errors occur before an invocation exists and
continue to be returned to the application without automatic printing.
Supported repetition and last-occurrence overrides are routine decisions.
Repeated singleton flags, including `--verbose --verbose`, may produce debug
detail; repetition alone does not warrant a warning. Repeatable flags retain
their intended accumulation behavior. Add warnings for concrete concerns an
operator can act on, such as a deprecated spelling if deprecation is introduced.
Unknown flags, invalid values, and invalid command paths remain invocation
failures with error diagnostics.
Start debug instrumentation with routing, alias resolution, verbosity selection,
and handler completion. Other useful candidates can be added where they
explain a consequential decision:
| Event | Useful detail |
| --- | --- |
| Command-alias resolution | Entered path, intermediate redirects, and final canonical path. |
| Flag-alias resolution | Entered spelling and canonical flag name. |
| Scoped alias selection | A command-scoped mapping took precedence over an application-wide mapping. |
| Verbosity selection | A later occurrence selected a different mode; for `--verbose --debug`, debug wins because it appears last. |
| Help-route selection | A canonical command or alias-only discovery context was selected. |
| Repeated singleton flag | A later occurrence replaced an earlier occurrence, without printing their values. |
| Input preparation | Canonical input validation completed and the handler is starting. |
| Handler completion | Canonical command and the handler's returned classification and status, before final delivery. |
The handler-completion event describes the handler's result. The final returned
invocation outcome also includes delivery failures and remains authoritative.
Present this event before terminal failure presentation; do not emit a completion
message afterward.
Use the final selected mode to present earlier parser decisions. Collect the
relevant events during parsing and apply visibility after mode selection:
`--verbose --debug` shows the selection explanation, while `--debug --quiet`
suppresses it. This collection of parser events does not require whole-run
output buffering.
Debug messages should explain operator-relevant decisions independently of
incidental implementation steps. Use command paths, flag names, and decision
descriptions rather than raw argument or configuration-value dumps. Upstream
libraries retain ownership of their diagnostic detail and disclosure rules.
## Selected delivery lifecycle and quiet execution (OD-4)
### Ordering and finalization
Use synchronous delivery. Present immediate messages and substantive writes in
submission order. After the handler returns:
1. Deliver eligible deferred messages in queue order, without sorting by severity.
2. Finish ordinary reporting, including any handler-completion debug message and
flushing any CommandKit-owned buffers for ordinary output. Account for failures
discovered during this delivery before preparing the terminal explanation.
3. Present any terminal failure explanation and complete its delivery, including
any CommandKit-owned buffering of that explanation.
4. Close the invocation's reporting facility and return the final outcome.
Deferred messages remain eligible when the operation fails, subject to verbosity.
Deferral controls timing; applications queue success summaries only after the
relevant operation succeeds. Ordering governs CommandKit's writes; separate
stdout and stderr destinations may be buffered or displayed differently by the
caller.
CommandKit finalizes automatically. Consumers do not need a separate flush or
finish call. Handler cleanup, including Go `defer` functions, runs before the
handler returns and participates in normal reporting. Applications complete any
reporting goroutines before returning. After finalization, writes and message
submissions return a closed-reporter error and emit nothing.
Reporter operations within an invocation are safe to call concurrently.
Serialize access to reporting state and delivery so individual messages do not
interleave. Concurrent submissions have no predetermined order; deferred messages
follow the order in which they enter the queue. Multiple writes composing one
application report still require application coordination if they must remain
together. This contract covers operations through one invocation's reporter;
coordination with writes outside that reporter remains caller-owned.
Finalize any buffers owned by CommandKit. Caller-supplied writers remain
caller-owned; CommandKit does not automatically close them or invoke arbitrary
flushing interfaces.
Visibility applies when output is emitted. A verbose or debug run may show
informational detail before subsequently failing. Progress and substantive
output may likewise be partially delivered before a failure. Previously written
bytes cannot be withdrawn. Whole-run buffering solely to make failed runs
contain only error output is outside this implementation.
Keep errors understandable in every mode, including source locations and
related explanatory context. Unrelated warnings and informational messages
remain subject to the selected mode. Apply the selected retention boundary
without accumulating a message history.
### Rendering failures, write failures, and execution flow
Distinguish who invokes a renderer and whether its destination failed:
| Failure source | Responsibility and handling |
| --- | --- |
| An encoder or renderer called directly by the application | The application handles its returned error and chooses the outcome and explanation. |
| A rendering callback invoked by CommandKit | CommandKit handles its returned error as a presentation failure and reflects it in the invocation outcome. |
| A destination rejects a write | CommandKit records the delivery failure and stops further writes to that destination. |
A rendering failure may occur before any write or while the destination remains
healthy. It does not by itself establish a failed destination. CommandKit can
explain its rendering failure through a working diagnostic destination. These
responsibilities also apply when rendering into a discard destination under
quiet mode.
Failure to deliver requested output makes an otherwise successful invocation
unsuccessful. Apply this rule to substantive output, Warning, Info, Progress,
Debug, and terminal explanations. Intentional suppression requests no delivery
and is not a delivery failure.
Standard writer operations return write errors normally. Immediate reporting
methods also expose delivery errors. CommandKit records bounded delivery-failure
information so an ignored write error cannot yield a successful invocation.
Deferred delivery failures appear in the final outcome.
Apply ADR 0005's outcome rules when presentation fails. For standard outcomes,
return operational failure with status `1`, including when the original outcome
was a usage failure. Preserve the original explanation and any supplied cause;
record the presentation failure separately in `Presentation`.
Application-defined domain outcomes retain their domain classification. Preserve
an already non-zero domain status. If the domain status was `0`, change the
suggested status to `1` so presentation failure cannot yield a successful
invocation. The application entry point retains control of process exit.
| Outcome before presentation fails | Final outcome |
| --- | --- |
| Success, status `0` | Operational failure, status `1`. |
| Usage failure, status `2` | Operational failure, status `1`. |
| Operational failure, status `1` | Operational failure, status `1`. |
| Domain outcome, non-zero status | Same domain classification and status. |
| Domain outcome, status `0` | Same domain classification, status `1`. |
These rules apply to both destination-write failures and rendering failures from
callbacks invoked by CommandKit. Reporting failures remain CommandKit's own
error-handling responsibility.
Stop writing to a destination after it fails. A substantive-output failure can
receive a terminal explanation through the diagnostic destination if it still
works. If diagnostic delivery fails, return that information to the caller.
Do not redirect diagnostics into substantive output or recursively attempt to
report the reporting failure.
A failed write does not stop application code. The handler can inspect the error
and return immediately; otherwise, CommandKit records the failure and finalizes
when the handler returns. CommandKit does not use process exit or panic to
interrupt execution. Terminal failure presentation ends normal output.
### Quiet-mode rendering and suppression
Initially, run substantive report generation normally with its managed writer
discarding output in quiet mode. This preserves report generation, validation,
and serialization failures while changing presentation. For example, a report
encoder failure still fails the invocation under `--quiet`; the handler remains
responsible for handling errors returned by an encoder it calls.
Simple suppressed messages need no formatting or destination writes. For richer
report renderers, initially render into a discard destination. Rendering may be
skipped later where doing so preserves the command's operation and failure
semantics.
Treat suppression as intentional suppression, never as failed or incomplete
delivery. Do not retain suppressed content. Delivery needs only the deferred
queue, reporting lifecycle state, and bounded delivery-failure information,
alongside the temporary parser state and returned explanations described above.
## Selected consumer reporting API
Provide one reporting facility per invocation for substantive output, immediate
messages, and end-of-run diagnostics. The consumer API is chosen for its intended
long-term design; with no current consumers, preserving existing API shapes
carries no weight in this decision.
The following declarations specify the target API and are not yet implemented.
The reporter owns presentation: output visibility, progress settings, prefixes,
ordering, and delivery bookkeeping. Its views share invocation state, allowing
already-emitted messages, deferred messages, suppression, and write failures to
be coordinated in one place. Helpers may receive the reporter or a narrow
application-defined interface. Domain libraries can continue returning
diagnostic data for the handler to submit, independently of CommandKit.
Use the reporter as the common diagnostic submission path, replacing the earlier
proposal to split submission between a reporter and `Outcome.Diagnostics`.
Terminal explanations accompany returned outcomes and are delivered through the
same facility. Caller-selected destinations remain at `App.Execute`; handlers
use `Invocation.Reporter()` in place of raw invocation streams.
### Invocation reporter and structured detail
```go
func (inv Invocation) Reporter() *Reporter
type Reporter struct {
// Private invocation state.
}
func (r *Reporter) Output() io.Writer
func (r *Reporter) RenderOutput(write func(io.Writer) error) error
func (r *Reporter) Info(message string, details ...Detail) error
func (r *Reporter) Warn(message string, details ...Detail) error
func (r *Reporter) Debug(message string, details ...Detail) error
func (r *Reporter) Progress(message string, details ...Detail) error
func (r *Reporter) AtEnd() DeferredReporter
type Detail func(io.Writer) error
```
`Output()` supports ordinary writers and encoders. `RenderOutput()` coordinates
an entire rendering operation, captures its rendering error, and prevents other
reporter operations from interleaving with it. Rendering callbacks write through
their supplied writer. Each complete categorized message, including its details,
is likewise coordinated as one reporter operation.
Rendering callbacks use their supplied writer only during the callback. They may
call rendering helpers that write through it. While a callback is running, it
must not recursively submit messages, queue deferred messages, start another
rendering operation, or write through a separate `Output()` handle belonging to
the same invocation's reporter. Make additional reporter submissions before or
after the callback. A callback must also avoid waiting for another goroutine to
submit through that reporter, since the submission may be waiting for the
callback to finish.
This boundary applies to `RenderOutput`, immediate and deferred message details,
and terminal explanation details. Ordinary concurrent reporter calls remain
supported and serialized. Keeping each rendering operation together motivates
the boundary; it does not prescribe a lock implementation or promise runtime
detection of recursive calls.
Message strings are literal; consumers use `fmt.Sprintf` when formatting is
needed. Initially omit parallel `Infof`, `Warnf`, and similar methods. CommandKit
formats the opening message with its category's prefix and a terminating newline.
Optional details follow in order with their formatting preserved. An empty
opening message produces no heading, prefix, or blank opening line, supporting
native renderers that supply a complete, labelled diagnostic:
```go
_ = report.Warn("Using the default profile")
_ = report.Warn("Some configuration settings need attention.", writeNativeWarnings)
_ = report.Warn("", writeNativeWarnings)
```
These are alternative calls; `writeNativeWarnings` represents an
application- or integration-supplied function with signature
`func(io.Writer) error`. No generic logging method or severity enumeration is
needed to submit messages. Applications need not check verbosity before reporting
or implement categories they do not use.
Immediate methods return rendering or delivery errors, and CommandKit records
the corresponding presentation failure even if the caller ignores the error.
A caller can inspect the returned error and end its handler immediately.
### Deferred messages
```go
type DeferredReporter struct {
// Private reference to the same invocation state.
}
func (r DeferredReporter) Info(message string, details ...Detail) error
func (r DeferredReporter) Warn(message string, details ...Detail) error
func (r DeferredReporter) Debug(message string, details ...Detail) error
```
For example:
```go
_ = report.AtEnd().Warn("Three optional files were unavailable.")
```
Copies of the deferred view share invocation state. Its methods return submission
errors, such as attempting to use a closed reporter. Errors encountered during
deferred rendering appear in the final invocation outcome. Progress remains an
immediate streaming operation; substantive output uses the main reporter.
Copy callback lists when accepting a submission. Callers retain ownership of
data captured by rendering callbacks and keep that data valid until presentation.
### Terminal outcomes
Outcome construction requires no invocation state and emits nothing:
```go
type Explanation struct {
Message string
Details []Detail
}
type Outcome struct {
Kind OutcomeKind
Status int
Domain string
Cause error
Explanation Explanation
Presentation PresentationResult
}
func Success() Outcome
func Failure(message string, details ...Detail) Outcome
func Usage(message string, details ...Detail) Outcome
func Domain(name string, status int) Outcome
func (o Outcome) WithExplanation(message string, details ...Detail) Outcome
func (o Outcome) WithCause(cause error) Outcome
```
`Success` returns a successful outcome with status `0`. `Failure` produces an
operational failure with status `1`; `Usage` produces a usage failure with status
`2`. `Domain` constructs an outcome with the supplied application-defined
classification and status. Subsequent presentation failures follow OD-4's rules:
domain classification is preserved, and a zero status becomes `1` if presentation
fails.
`OutcomeKind` retains the success, usage, operational, and domain classifications.
`WithExplanation` returns a modified outcome value and replaces its terminal
explanation. Explanations belong to failed outcomes; successful summaries use
Info or substantive output. Ordinary application failure, native failure
rendering, and a custom domain failure respectively can be expressed as:
```go
return commandkit.Failure("Unable to export the requested report.")
```
```go
return commandkit.Failure("", writeNativeFailure)
```
```go
return commandkit.Domain("validation", 3).
WithExplanation("The document failed validation.", writeFindings)
```
The named renderers above have signature `func(io.Writer) error` and preserve the
owning library's safe diagnostic formatting. They run during terminal delivery,
not when constructing the outcome.
Before terminal delivery, supply the explanation `Command failed.` when a
non-zero outcome has an empty `Explanation.Message` and no non-nil callback in
`Explanation.Details`. This produces `ERROR: Command failed.` in every mode,
including quiet, and becomes the explanation retained in the returned outcome.
Adding the fallback leaves classification, status, and cause unchanged;
presentation failures still follow OD-4's rules. The fallback is based on the
failed outcome and absent explanation, without deriving text from `Cause.Error()`.
An empty heading with a non-nil detail renderer is an explicitly supplied
explanation. Trust that renderer to provide its content. Do not buffer or replay
it to decide whether the explanation is sufficiently informative, and do not
add the fallback merely because it writes no bytes.
| Returned outcome | Terminal presentation |
| --- | --- |
| `Failure("")` | Generic fallback, operational classification and status `1`. |
| `Domain("validation", 3)` | Generic fallback, domain classification and status `3`. |
| `Failure("").WithCause(err)` | Generic fallback; the supplied cause remains available without automatic rendering. |
| `Failure("", writeNativeFailure)` | Native explanation without an added generic heading. |
`Cause` is optional. `WithCause` returns a modified outcome value, replacing the
cause without changing the classification, status, or explanation. For example:
```go
return commandkit.Failure("Unable to open the configuration file.").
WithCause(err)
```
CommandKit presents the supplied explanation and carries the original error
through to the execution caller, which can inspect it with expressions such as
`errors.Is(outcome.Cause, fs.ErrNotExist)`. It does not automatically present
`Cause.Error()` or use the cause to decide success, failure, or message category.
Providing a cause is independent of choosing the terminal explanation; neither
is inferred from the other. Warnings do not require supplying or retaining causes.
Outcome processing preserves supplied causes, including their existing Go error
identity, when a subsequent rendering or delivery failure occurs. Presentation
failures are recorded separately in `Presentation`. The returned outcome retains
its terminal explanation and optional cause, with other retention limited to the
presentation needs described above.
### Provider-returned diagnostic data
Providers return values and diagnostic data without needing a reporter parameter.
Retain `Acquired[T]` with separate fields for independent messages and failure
explanations:
```go
type Acquired[T any] struct {
Value T
Diagnostics []Diagnostic
Explanation Explanation
}
```
`Diagnostics` carries independent, nonterminal messages. `Diagnostic` and
`Severity` remain passive data types for those messages, with each diagnostic
carrying its `Severity` and a `Write func(io.Writer) error` renderer. Adapt their
contracts to the nonterminal reporting categories. Integrations submit the
records through the corresponding reporter methods, including the deferred view
where appropriate. Removing `Outcome.Diagnostics` removes a submission path;
provider-returned records remain available for the integration to submit.
`Explanation` carries context to attach when the integration returns a failed
outcome. Terminal Error content uses this field rather than an independently
submitted diagnostic. An upstream warning that helps explain the failure may
belong in the explanation; unrelated warnings remain in `Diagnostics` and follow
their ordinary visibility rules. Adapters preserve this distinction and useful
native formatting when translating mixed upstream batches, instead of assigning
the whole batch its highest severity.
Providers may return acquired data alongside a Go error. The owning integration
chooses the command outcome, attaches any terminal explanation, and decides which
cause to preserve. Returning diagnostic data does not itself emit messages or
select the invocation outcome. Diagnostic renderers follow the same callback
boundary as other details when invoked through the reporter.
### Presentation observations and closed reporters
```go
type PresentationResult struct {
Output DeliveryResult
Diagnostics DeliveryResult
}
type DeliveryResult struct {
State DeliveryState
RenderError error
WriteError error
}
type DeliveryState uint8
const (
DeliveryNone DeliveryState = iota
DeliverySuppressed
DeliveryComplete
DeliveryIncomplete
)
var ErrReporterClosed = errors.New("reporter is closed")
```
| State | Meaning |
| --- | --- |
| `DeliveryNone` | No content was submitted for this destination. |
| `DeliverySuppressed` | All submitted content was intentionally suppressed, and rendering succeeded. |
| `DeliveryComplete` | All requested delivery completed; other content may have been suppressed. |
| `DeliveryIncomplete` | Rendering or destination writing failed. |
Retain the first rendering failure and first destination-write failure for each
destination. This provides bounded evidence without collecting an event history.
`Outcome.Presentation` is populated by CommandKit during execution and
finalization; it is observation data, not another submission API.
The invocation supplies the reporter. Consumers neither construct nor finalize
it. After closure, message submissions and writer operations return
`ErrReporterClosed`, including in quiet mode. The initial API needs no explicit
flush method, transcript accessor, or verbosity checks. Error-conversion helpers
are deferred pending demonstrated value.
### Application example
This target-API handler uses an application-owned `exportData` operation that
returns a JSON-encodable result with a `UsedDefaults` field:
```go
func runExport(inv commandkit.Invocation) commandkit.Outcome {
report := inv.Reporter()
result, err := exportData(inv.Context)
if err != nil {
return commandkit.Failure("Unable to export the requested data.")
}
if result.UsedDefaults {
_ = report.Warn("Some optional settings used defaults.")
}
if err := report.RenderOutput(func(w io.Writer) error {
return json.NewEncoder(w).Encode(result)
}); err != nil {
return commandkit.Failure("Unable to present the export result.")
}
_ = report.AtEnd().Info("Export completed.")
return commandkit.Success()
}
```
Ignored immediate reporting errors are still recorded and can make the final
invocation unsuccessful. The renderer runs in quiet mode against the discard
destination, preserving serialization failures. The deferred summary follows
the selected verbosity policy and describes the completed export operation;
the final invocation outcome also accounts for presentation failures.
## Upstream diagnostic ownership
ConfigKit and PromptKit remain authoritative for their diagnostic semantics,
severity, source context, and disclosure rules. Integration adapters expose
those capabilities through the shared CommandKit presentation policy.
Support consistent reporting of ConfigKit errors and warnings as its adopted
public API evolves. This roadmap does not assume that a future warning API
already exists or prescribe its upstream shape. Integration-owned commands
select the outcome and submit useful native diagnostic detail for presentation.
Core presents those records under the shared policy. Applications using upstream
libraries directly choose how to respond to domain-specific errors and which
outcomes and messages to supply, as for their other operations.
An integration may receive both a useful substantive report and an operation
failure, as PromptKit smoke testing supports. Preserve the report under the
ordinary substantive-output policy, then present the terminal explanation.
Quiet mode suppresses the report while retaining the explanation. Having a
report does not imply a successful outcome, and returning a failure does not
discard substantive output already prepared for delivery before the terminal step.
Debug output follows the same upstream disclosure and safe-rendering contracts.
Enabling debug does not authorize secret disclosure or bypass owning-library
rules.
## Delivery scope
The completed feature includes the selected reporter and outcome API, ordered
mode/progress selection through normal parsing, shared core presentation, and
both optional integration migrations. Passive provider diagnostics distinguish
independent messages from terminal explanations while preserving native evidence
and rendering. Existing command behavior remains unchanged outside the selected
reporting policy.
Maintained consumers demonstrate ordinary output, immediate/deferred messages,
silent recovery, explicit warnings, failure after partial output, optional cause
transport, and structured terminal explanations. Integration consumers include
native diagnostic context and useful reports returned alongside failures.
Behavioral coverage establishes CommandKit-owned parsing, visibility, ordering,
concurrent reporter coordination, closure, presentation observations, and failure
handling. It preserves existing alias, input/state, discovery, native-report,
and upstream-adapter protections without testing upstream domain correctness.
GoDoc, CLI documentation, consumer guidance, internal documentation, and release
notes describe the implemented boundary under their canonical ownership.
The [implementation plan](implementation.md) defines the concrete migration
steps, focused validation, and completion criteria. This roadmap defines the
target behavior and selected policy choices.
## Architectural preparation completed
- [x] Review and accept [ADR 0014](../adr/0014-invocation-reporting.md) as an
extension for shared reporting, retaining ADR 0005's outcome and supplied-error
preservation decisions.
- [x] Align the [architecture policy](../policy/architecture.md) with the reporting
ownership model, including application ownership of domain-specific error
decisions and scope for useful shared error helpers.
OD-1 through OD-4 and the consumer API signatures are selected. The target end
state includes maintained, executable consumers using those APIs; the
[implementation plan](implementation.md) owns their delivery sequence.

31
examples/core/README.md Normal file
View File

@@ -0,0 +1,31 @@
# Core example
Run the example from the repository root:
```sh
go run ./examples/core hello --times=0 --tag=first
go run ./examples/core ws greet --count=2
go run ./examples/core ws --help
go run ./examples/core hello --hlep
```
The example installs a canonical `workspace greet` command together with a
command shortcut, a group shortcut, a scoped flag alias, and a global help
alias. Its declaration slice mixes the canonical group and routing rules as
`[]commandkit.Declaration`. The handler prints `Invocation.EnteredPath()` and
`Invocation.CanonicalPath()` so the alternate route and its canonical target
are visible in the output.
Integration factories also return `commandkit.Command` and can be passed
directly to `New` beside `commandkit.Alias` or flag-alias declarations. For a
command-only `[]commandkit.Command`, convert its elements into a
`[]commandkit.Declaration` before appending routing rules; Go cannot expand the
command slice as `...commandkit.Declaration`. The
[consumer guide](../../docs/consumers/guide.md#application-owned-aliases)
shows both forms, and the [composed consumer](../../integrations/ckprompt/examples/composed)
is the maintained integration example.
These aliases use the current local CommandKit source. Published CommandKit
v0.2.0 does not include alias declarations; consumers can use them after the
next core release. Repository contributors can run its test with
`go test ./examples/core` from the root.

View File

@@ -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,14 +50,93 @@ 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\n", count, tags); 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)
return commandkit.New(commandkit.Identity{Name: "core-example", Version: "1.0"}, workspace)
declarations := []commandkit.Declaration{
workspace,
report,
commandkit.Alias("hello", "workspace greet"),
commandkit.Alias("ws", "workspace"),
commandkit.FlagAliasFor("workspace greet", "--times", "--count"),
commandkit.FlagAlias("--hlep", "--help"),
}
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() {

199
examples/core/main_test.go Normal file
View File

@@ -0,0 +1,199 @@
package main
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
func TestAliasesReachCoreExampleCommand(t *testing.T) {
app, err := newApp()
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
args []string
entered string
canonical string
greeting string
}{
{name: "command shortcut and scoped flag", args: []string{"hello", "--times=0", "--tag", "one"}, entered: "hello", canonical: "workspace greet", greeting: "count=explicit 0; tags=explicit [one]"},
{name: "group shortcut", args: []string{"ws", "greet", "--count=2"}, entered: "ws greet", canonical: "workspace greet", greeting: "count=explicit 2"},
{name: "canonical command", args: []string{"workspace", "greet"}, entered: "workspace greet", canonical: "workspace greet", greeting: "count=default 1 (not supplied)"},
} {
t.Run(test.name, func(t *testing.T) {
var output bytes.Buffer
outcome := app.Execute(context.Background(), test.args, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("Execute(%q) = %#v; output %q", test.args, outcome, output.String())
}
for _, fragment := range []string{
test.greeting,
"entered=\"" + test.entered + "\"",
"canonical=\"" + test.canonical + "\"",
} {
if !strings.Contains(output.String(), fragment) {
t.Errorf("output %q does not contain %q", output.String(), fragment)
}
}
})
}
}
func TestGlobalHelpAliasDoesNotRunCoreExampleCommand(t *testing.T) {
app, err := newApp()
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"hello", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--hlep → --help") || strings.Contains(output.String(), "Hello;") {
t.Fatalf("help alias outcome/output = (%#v, %q)", outcome, output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"ws", "--help"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "greet") || strings.Contains(output.String(), "Hello;") {
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()
}

View File

@@ -5,52 +5,85 @@ import (
"errors"
"fmt"
"io"
"strconv"
"strings"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
// Execute parses and dispatches one invocation. args excludes the executable
// name. Supplied values are converted before command input construction; help
// does not construct caller inputs or run acquisition callbacks. 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.
// does not construct caller inputs or run acquisition callbacks. It applies the
// parsed reporting selection, finalizes invocation presentation, and returns
// the resulting Outcome without exiting the process. Supplied streams remain
// caller-owned; nil destinations are replaced with io.Discard. A nil context
// returns an operational failure.
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)
current := newExecution(a, ctx, streams)
if a == nil {
err := errors.New("cannot execute commands on a nil app")
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
}
if ctx == nil {
err := errors.New("invocation context must not be nil")
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
}
routedSnapshot := current.snapshot
parsed := current.parse(args)
current.route = resolveEnteredRoute(args, routedSnapshot)
current.recordRouteEvents()
parsed := current.parse(current.route.args)
current.reporter.applySelection(parsed.Reporting)
current.recordSelectionEvents(parsed)
current.submitDebugEvents()
var outcome Outcome
path := parsed.Path
handlerRan := false
if parsed.Failure != nil {
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path)
}
payload, ok := routedSnapshot.runners[parsed.Key]
if !ok || payload == nil {
return current.present(current.renderHelp(parsed.Path), parsed.Path)
}
if parsed.HelpRequested || !payload.hasRun() {
if _, err := payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
return current.present(Usage(err), parsed.Path)
outcome = Usage(parsed.Failure.Message)
} else if current.route.kind == routeDiscovery {
if parsed.HelpRequested {
outcome = current.renderHelp(parsed.Path)
path = clonePath(current.route.enteredPath)
} else {
entered := current.route.enteredPath.String()
outcome = Usage(fmt.Sprintf("incomplete command path %q; use %q --help", entered, entered))
path = clonePath(current.route.enteredPath)
}
} else {
payload, ok := routedSnapshot.runners[parsed.Key]
if !ok || payload == nil {
outcome = current.renderHelp(parsed.Path)
} else if parsed.HelpRequested || !payload.hasRun() {
if _, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, false); err != nil {
outcome = classifyInputError(err)
} else {
outcome = current.renderHelp(parsed.Path)
}
} else {
run, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, true)
if err != nil {
outcome = classifyInputError(err)
} else {
handlerRan = true
outcome = run(current)
}
}
return current.present(current.renderHelp(parsed.Path), parsed.Path)
}
run, err := payload.prepare(parsed.FlagValues, parsed.Operands, true)
if err != nil {
return current.present(Usage(err), parsed.Path)
}
return current.present(run(current), parsed.Path)
return current.finalize(outcome, path, handlerRan)
}
type execution struct {
app *App
ctx context.Context
streams Streams
options commandmeta.ParseOptions
snapshot *routingSnapshot
session *internalcobra.Session
app *App
ctx context.Context
streams Streams
reporter *Reporter
debugEvents []string
options commandmeta.ParseOptions
snapshot *routingSnapshot
session *internalcobra.Session
route routeResult
}
type commandExecution struct {
@@ -60,25 +93,35 @@ type commandExecution struct {
func (current *execution) commandContext() commandExecution {
return commandExecution{
invocation: Invocation{Context: current.ctx, Streams: current.streams},
invocation: current.invocation(),
renderHelpTargetsFunc: current.renderHelpTargets,
}
}
func (current *execution) invocation() Invocation {
return Invocation{
Context: current.ctx,
reporter: current.reporter,
enteredPath: current.route.enteredPath.String(),
canonicalPath: current.route.canonicalPath.String(),
}
}
func (current commandExecution) renderHelpTargets(targets []string) Outcome {
return current.renderHelpTargetsFunc(targets)
}
func newExecution(app *App, ctx context.Context, streams Streams) *execution {
streams = normalizeStreams(streams)
current := &execution{
app: app,
ctx: ctx,
streams: streams,
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
app: app,
ctx: ctx,
streams: streams,
reporter: newReporter(streams),
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
}
if app != nil {
current.snapshot = app.routeSnapshot()
current.session = internalcobra.Prepare(current.snapshot.metadata, current.options)
}
return current
}
@@ -87,7 +130,34 @@ func (current *execution) parse(args []string) commandmeta.Result {
if current.app == nil {
return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
}
return current.session.Parse(args)
if current.session == nil {
current.prepareSession()
}
return reduceReportingOccurrences(current.session.Parse(args))
}
func (current *execution) prepareSession() {
if current.app == nil {
return
}
if current.snapshot == nil {
current.snapshot = current.app.routeSnapshot()
}
options := current.options
if current.snapshot != nil {
switch current.route.kind {
case routeCanonical:
options.FlagAliases = current.snapshot.flagAliases[current.route.canonicalPath.Key()].neutralRecords()
case routeDiscovery:
options.FlagAliases = current.snapshot.discoveryFlags[current.route.discoveryPath.Key()].neutralRecords()
default:
snapshot := current.snapshot
options.FlagAliasesForPath = func(path []string) []commandmeta.FlagAlias {
return snapshot.flagAliases[commandpath.FromTokens(path).Key()].neutralRecords()
}
}
current.session = internalcobra.Prepare(current.snapshot.metadata, options)
}
}
func (current *execution) presentationSession() *internalcobra.Session {
@@ -97,23 +167,50 @@ func (current *execution) presentationSession() *internalcobra.Session {
snapshot := current.app.routeSnapshot()
if current.session == nil || current.snapshot != snapshot {
current.snapshot = snapshot
current.session = internalcobra.Prepare(snapshot.metadata, current.options)
current.session = nil
current.prepareSession()
}
return current.session
}
func (current *execution) renderHelp(path []string) Outcome {
if current.app == nil {
return Failure(errors.New("cannot render help on a nil app"))
err := errors.New("cannot render help on a nil app")
return Failure(err.Error()).WithCause(err)
}
return current.app.renderHelpOutcomeWithSession(path, current.streams, current.presentationSession())
session := current.presentationSession()
view := current.app.helpView(current.route, current.snapshot)
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
}
func (current *execution) renderHelpTargets(targets []string) Outcome {
if current.app == nil {
return Failure(errors.New("cannot render help on a nil app"))
err := errors.New("cannot render help on a nil app")
return Failure(err.Error()).WithCause(err)
}
return current.app.renderHelpTargetsWithSession(targets, current.streams, current.presentationSession())
snapshot := current.app.routeSnapshot()
if snapshot == nil {
err := errors.New("cannot render help without command metadata")
return Failure(err.Error()).WithCause(err)
}
route := resolveEnteredRoute(targets, snapshot)
// Explicit help targets are paths, not command invocations with operands.
// Discovery routes have no canonical path and retain only trailing options.
if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) {
session := internalcobra.Prepare(snapshot.metadata, current.options)
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
}
options := current.options
switch route.kind {
case routeCanonical:
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
case routeDiscovery:
options.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords()
}
session := internalcobra.Prepare(snapshot.metadata, options)
view := current.app.helpView(route, snapshot)
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
}
func (current *execution) usageLine(path []string) string {
@@ -129,71 +226,133 @@ func (current *execution) usageLine(path []string) string {
return current.app.usageFallback()
}
func (current *execution) present(outcome Outcome, path []string) Outcome {
return current.app.presentWithUsageLine(outcome, path, current.streams, current.usageLine)
func (current *execution) routeUsageLine() string {
line := current.usageLine(clonePath(current.route.canonicalPath))
canonical := strings.TrimSpace(current.app.identity.Name + " " + current.route.canonicalPath.String())
entered := strings.TrimSpace(current.app.identity.Name + " " + current.route.enteredPath.String())
line = strings.Replace(line, "Usage: "+canonical, "Usage: "+entered, 1)
return line + "\nAlias for: " + canonical
}
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) present(outcome Outcome, path []string, streams Streams) Outcome {
return a.presentWithUsageLine(outcome, path, streams, a.usageLine)
}
func (a *App) presentWithUsageLine(outcome Outcome, path []string, streams Streams, usageLine func([]string) string) Outcome {
if outcome.Kind == "" && outcome.Status == 0 && outcome.Domain == "" &&
outcome.Cause == nil && outcome.DeliveryError == nil &&
!outcome.ReportIncomplete && len(outcome.Diagnostics) == 0 {
outcome.Kind = OutcomeSuccess
}
if outcome.DiagnosticDelivery == "" {
outcome.DiagnosticDelivery = DeliveryNone
}
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(SeverityError, outcome.Cause.Error()))
}
if outcome.Kind == OutcomeUsage {
diagnostics = append(diagnostics, MessageDiagnostic(SeverityError, usageLine(path)))
}
outcome.Diagnostics = diagnostics
if len(diagnostics) == 0 {
return outcome
}
delivered := false
for _, diagnostic := range diagnostics {
if diagnostic.Write == nil {
continue
func (current *execution) recordRouteEvents() {
entered := current.route.enteredPath.String()
canonical := current.route.canonicalPath.String()
switch current.route.kind {
case routeCanonical:
if entered == canonical {
if canonical == "" {
current.debugEvents = append(current.debugEvents, "selected the application root command")
} else {
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected command path %q", canonical))
}
} else {
current.debugEvents = append(current.debugEvents, fmt.Sprintf("resolved command alias %q to %q", entered, canonical))
}
delivered = true
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)
case routeDiscovery:
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected discovery route for prefix %q", entered))
default:
if entered != "" {
current.debugEvents = append(current.debugEvents, fmt.Sprintf("no canonical route matched command path %q", entered))
}
}
}
func (current *execution) recordSelectionEvents(result commandmeta.Result) {
mode := "ordinary"
flagName := ""
switch result.Reporting.Mode {
case commandmeta.ReportingModeQuiet:
mode, flagName = "quiet", "quiet"
case commandmeta.ReportingModeVerbose:
mode, flagName = "verbose", "verbose"
case commandmeta.ReportingModeDebug:
mode, flagName = "debug", "debug"
}
event := "selected ordinary reporting mode"
if flagName != "" {
event = fmt.Sprintf("selected %s reporting mode", mode)
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
occurrence := result.FlagOccurrences[index]
if occurrence.Name != flagName {
continue
}
enabled, err := strconv.ParseBool(occurrence.RawValue)
if err != nil || !enabled {
continue
}
canonical := "--" + flagName
if occurrence.Spelling != canonical {
event += fmt.Sprintf(" using %s (alias for %s)", occurrence.Spelling, canonical)
} else {
event += fmt.Sprintf(" using %s", canonical)
}
break
}
}
current.debugEvents = append(current.debugEvents, event)
if result.Reporting.ProgressSet {
state := "disabled"
if result.Reporting.Progress {
state = "enabled"
}
event := "explicitly " + state + " progress output"
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
occurrence := result.FlagOccurrences[index]
if occurrence.Name == "progress" {
if occurrence.Spelling != "--progress" {
event += fmt.Sprintf(" using %s (alias for --progress)", occurrence.Spelling)
}
break
}
}
if writeErr != nil {
return deliveryFailure(outcome, writeErr)
current.debugEvents = append(current.debugEvents, event)
}
}
func (current *execution) submitDebugEvents() {
events := current.debugEvents
current.debugEvents = nil
for _, event := range events {
_ = current.reporter.Debug(event)
}
}
func (current *execution) finalize(outcome Outcome, path []string, handlerRan bool) Outcome {
if outcome.Kind == OutcomeUsage {
details := append([]Detail(nil), outcome.Explanation.Details...)
details = append(details, func(w io.Writer) error {
line := current.usageText(path)
if !strings.HasSuffix(line, "\n") {
line += "\n"
}
_, err := io.WriteString(w, line)
return err
})
outcome.Explanation.Details = details
}
var completion func(*Reporter)
if handlerRan {
completion = func(reporter *Reporter) {
kind := outcome.Kind
if kind == "" && outcome.Status == 0 && outcome.Domain == "" {
kind = OutcomeSuccess
}
path := current.route.canonicalPath.String()
_ = reporter.Debug(fmt.Sprintf("handler completed for %q with %s outcome and status %d", path, kind, outcome.Status))
}
}
if delivered {
outcome.DiagnosticDelivery = DeliveryComplete
return current.reporter.finalize(outcome, completion)
}
func (current *execution) usageText(path []string) string {
switch {
case current.route.kind == routeDiscovery && current.app != nil:
return fmt.Sprintf("Usage: %s %s [command]", current.app.identity.Name, current.route.enteredPath.String())
case current.route.kind == routeCanonical && !current.route.enteredPath.Equal(current.route.canonicalPath):
return current.routeUsageLine()
default:
return current.usageLine(path)
}
return outcome
}
func (a *App) usageLine(path []string) string {
@@ -212,42 +371,3 @@ func (a *App) usageFallback() string {
}
return fmt.Sprintf("Usage: %s [command]", name)
}
func hasErrorDiagnostic(diagnostics []Diagnostic) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Write != nil && diagnostic.Severity == 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
}

View File

@@ -19,7 +19,7 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
}
var inputs []input
var contexts []context.Context
var streams []commandkit.Streams
var reporters []*commandkit.Reporter
firstContext := context.WithValue(context.Background(), struct{}{}, "first")
secondContext := context.WithValue(context.Background(), struct{}{}, "second")
var firstOut, secondOut, firstDiagnostic, secondDiagnostic bytes.Buffer
@@ -32,7 +32,12 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome {
inputs = append(inputs, value)
contexts = append(contexts, invocation.Context)
streams = append(streams, invocation.Streams)
reporter := invocation.Reporter()
reporters = append(reporters, reporter)
_ = reporter.RenderOutput(func(writer io.Writer) error {
_, err := io.WriteString(writer, value.name.Value+"\n")
return err
})
return commandkit.Success()
},
})
@@ -52,11 +57,11 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
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 reporters[0] == nil || reporters[1] == nil || reporters[2] == nil || reporters[0] == reporters[1] || reporters[0] == reporters[2] || reporters[1] == reporters[2] {
t.Fatalf("invocations did not receive fresh reporters: %#v", reporters)
}
if contexts[0] != firstContext || contexts[1] != secondContext || streams[0].Out != &firstOut || streams[0].Err != &firstDiagnostic || streams[1].Out != &secondOut || streams[1].Err != &secondDiagnostic {
t.Fatalf("handler invocation contexts or streams were not forwarded independently")
if firstOut.String() != "first\n" || secondOut.String() != "\n" || contexts[0] != firstContext || contexts[1] != secondContext {
t.Fatalf("handler invocation contexts or reporter destinations were not independent: out=%q/%q contexts=%v/%v", firstOut.String(), secondOut.String(), contexts[0], contexts[1])
}
}
@@ -65,7 +70,7 @@ func TestOmittedInputFactoryUsesExactPointerZeroValue(t *testing.T) {
Name: "run",
Run: func(_ commandkit.Invocation, value *int) commandkit.Outcome {
if value != nil {
return commandkit.Failure(errors.New("omitted input factory allocated a pointer"))
return commandkit.Failure("omitted input factory allocated a pointer")
}
return commandkit.Success()
},
@@ -136,9 +141,9 @@ func TestExecutePresentsHandlerUsageAfterRegisteringCommand(t *testing.T) {
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
calls++
if err := app.Register(late); err != nil {
return commandkit.Failure(err)
return commandkit.Failure(err.Error()).WithCause(err)
}
return commandkit.Usage(usageCause)
return commandkit.Usage("The handler requests usage.").WithCause(usageCause)
},
})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
@@ -195,12 +200,11 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
domainCause := errors.New("domain rejected the request")
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "review needed")
command := 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}
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
_ = invocation.Reporter().Warn("review needed")
outcome := commandkit.Domain("sample", 2).WithExplanation("The request was rejected.").WithCause(domainCause)
return outcome
},
})
@@ -209,19 +213,18 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
t.Fatal(err)
}
var diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
outcome := app.Execute(context.Background(), []string{"run", "--verbose"}, 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 {
if diagnostic.String() != "WARN: review needed\nERROR: The request was rejected.\n" || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
}
warningOnly := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "warn",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
result := commandkit.Success()
result.Diagnostics = []commandkit.Diagnostic{warning}
return result
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
_ = invocation.Reporter().Warn("review needed")
return commandkit.Success()
},
})
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
@@ -229,23 +232,18 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
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" {
outcome = app.Execute(context.Background(), []string{"warn", "--verbose"}, commandkit.Streams{Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "WARN: review needed\n" {
t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String())
}
}
func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T) {
cause := errors.New("already shown")
func TestExecuteDoesNotPresentOperationCauseWithoutExplanation(t *testing.T) {
cause := errors.New("private operation detail")
command := 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: commandkit.SeverityError, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "presented\n")
return err
}}}
return outcome
return commandkit.Domain("sample", 2).WithCause(cause)
},
})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
@@ -254,21 +252,11 @@ func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T)
}
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())
if outcome.Kind != commandkit.OutcomeDomain || !errors.Is(outcome.Cause, cause) {
t.Fatalf("domain outcome changed: %#v", outcome)
}
diagnostic.Reset()
completed := commandkit.Domain("sample", 2, cause)
completed.DiagnosticDelivery = commandkit.DeliveryComplete
command = commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "done", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return completed }})
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())
if strings.Contains(diagnostic.String(), cause.Error()) || !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
t.Fatalf("cause was presented instead of the fallback explanation: %#v, %q", outcome, diagnostic.String())
}
}
@@ -278,9 +266,7 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
outcome := commandkit.Failure(operationFailure)
outcome.ReportIncomplete = true
return outcome
return commandkit.Failure("The operation was canceled.").WithCause(operationFailure)
},
})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
@@ -288,12 +274,12 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
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) {
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.Presentation.Diagnostics.WriteError, 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 {
if !errors.Is(short.Presentation.Diagnostics.WriteError, io.ErrShortWrite) || short.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
t.Fatalf("short write was not detected: %#v", short)
}
}
@@ -339,7 +325,7 @@ func TestExecuteRunsApplicationOwnedNestedHelp(t *testing.T) {
)},
Run: func(_ commandkit.Invocation, value input) commandkit.Outcome {
received = append(received, value)
return commandkit.Domain("session-help", 7, domainCause)
return commandkit.Domain("session-help", 7).WithExplanation("Session help is unavailable.").WithCause(domainCause)
},
})
session := commandkit.Group("session", "Session commands.", help)
@@ -463,7 +449,7 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
t.Fatalf("version invoked an application command: %#v calls=%d/%d", versionOutcome, factoryCalls, runCalls)
}
failed := app.Execute(context.Background(), []string{"config", "show", "--help"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic})
if failed.Kind != commandkit.OutcomeOperational || !failed.ReportIncomplete || !errors.Is(failed.DeliveryError, io.ErrShortWrite) {
if failed.Kind != commandkit.OutcomeOperational || failed.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(failed.Presentation.Output.WriteError, io.ErrShortWrite) {
t.Fatalf("failed help delivery = %#v", failed)
}
out.Reset()
@@ -503,7 +489,7 @@ func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) {
t.Fatalf("invalid version scope = %#v, output %q", invalid, out.String())
}
failed := app.Execute(context.Background(), []string{"version"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic})
if failed.Kind != commandkit.OutcomeOperational || !failed.ReportIncomplete || !errors.Is(failed.DeliveryError, io.ErrShortWrite) {
if failed.Kind != commandkit.OutcomeOperational || failed.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(failed.Presentation.Output.WriteError, io.ErrShortWrite) {
t.Fatalf("failed version delivery = %#v", failed)
}
}
@@ -532,11 +518,11 @@ func TestHelpEntryFormsPresentReportAndDiagnosticFailures(t *testing.T) {
for _, args := range [][]string{nil, {"notes"}, {"--help"}, {"-h"}, {"notes", "--help"}, {"help"}, {"help", "notes"}} {
var diagnostics bytes.Buffer
result := app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: &diagnostics})
if result.Kind != commandkit.OutcomeOperational || !result.ReportIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || result.DiagnosticDelivery != commandkit.DeliveryComplete || strings.Count(diagnostics.String(), reportErr.Error()) != 1 {
if result.Kind != commandkit.OutcomeOperational || result.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Output.WriteError, reportErr) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete || (!strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output")) {
t.Fatalf("%v: result=%#v diagnostics=%q", args, result, diagnostics.String())
}
result = app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: failingWriter{diagnosticErr}})
if result.DiagnosticDelivery != commandkit.DeliveryIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || !errors.Is(result.DeliveryError, diagnosticErr) {
if result.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Output.WriteError, reportErr) || result.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Diagnostics.WriteError, diagnosticErr) {
t.Fatalf("%v: result=%#v", args, result)
}
}
@@ -560,46 +546,34 @@ func TestExecuteNormalizesOutcomeDefaultsAndPreservesDomainValues(t *testing.T)
if err != nil {
t.Fatal(err)
}
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
wantDelivery := commandkit.DeliveryNone
if tc.cause != nil {
wantDelivery = commandkit.DeliveryComplete
}
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause || result.DiagnosticDelivery != wantDelivery {
var diagnostic bytes.Buffer
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause {
t.Fatalf("result=%#v expected=%#v", result, tc)
}
if tc.status != 0 && !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
t.Fatalf("failed outcome omitted its fallback explanation: %q", diagnostic.String())
}
if tc.cause != nil && strings.Contains(diagnostic.String(), tc.cause.Error()) {
t.Fatalf("operation cause was presented automatically: %q", diagnostic.String())
}
}
}
func TestNilDiagnosticWritersHaveNoOutputAndDoNotHideCauses(t *testing.T) {
func TestNilDiagnosticWriterDoesNotHideOperationCause(t *testing.T) {
cause := errors.New("operation failed")
for _, base := range []commandkit.Outcome{commandkit.Success(), commandkit.Domain("needs action", 2, nil), commandkit.Failure(cause)} {
base.Diagnostics = []commandkit.Diagnostic{{}, {Severity: commandkit.SeverityWarning}, {Severity: commandkit.SeverityError}}
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return base }})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
var diagnostics bytes.Buffer
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostics})
want := commandkit.DeliveryNone
if base.Cause != nil {
want = commandkit.DeliveryComplete
}
if result.Kind != base.Kind || result.Status != base.Status || result.Domain != base.Domain || result.Cause != base.Cause || result.DeliveryError != nil || result.DiagnosticDelivery != want {
t.Fatalf("result=%#v base=%#v", result, base)
}
if base.Cause == nil && diagnostics.Len() != 0 {
t.Fatalf("unexpected output=%q", diagnostics.String())
}
if base.Cause != nil && strings.Count(diagnostics.String(), cause.Error()) != 1 {
t.Fatalf("cause output=%q", diagnostics.String())
}
if base.Cause != nil {
result = app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{io.ErrClosedPipe}})
if !errors.Is(result.Cause, cause) || !errors.Is(result.DeliveryError, io.ErrClosedPipe) || result.DiagnosticDelivery != commandkit.DeliveryIncomplete {
t.Fatalf("failed delivery=%#v", result)
}
}
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
return commandkit.Failure("The operation failed.").WithCause(cause)
},
})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, cause) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
t.Fatalf("nil diagnostic destination changed the outcome: %#v", result)
}
}

189
execute_reporting_test.go Normal file
View File

@@ -0,0 +1,189 @@
package commandkit
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
)
func TestExecuteGivesEachInvocationItsReporterAndAppliesSelection(t *testing.T) {
var reporters []*Reporter
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(inv Invocation, _ struct{}) Outcome {
reporter := inv.Reporter()
reporters = append(reporters, reporter)
if _, err := fmt.Fprint(reporter.Output(), "result\n"); err != nil {
t.Errorf("write result: %v", err)
}
if err := reporter.Warn("attention"); err != nil {
t.Errorf("submit warning: %v", err)
}
return Outcome{
Kind: OutcomeDomain, Domain: "accepted", Status: 0,
Presentation: PresentationResult{
Output: DeliveryResult{State: DeliveryIncomplete, WriteError: io.ErrClosedPipe},
},
}
},
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
var quietOut, quietErr bytes.Buffer
quiet := app.Execute(context.Background(), []string{"run", "--quiet"}, Streams{Out: &quietOut, Err: &quietErr})
if quiet.Kind != OutcomeDomain || quiet.Status != 0 || quiet.Domain != "accepted" {
t.Fatalf("quiet outcome = %#v", quiet)
}
if quietOut.Len() != 0 || quietErr.Len() != 0 {
t.Fatalf("quiet output = %q, diagnostics = %q", quietOut.String(), quietErr.String())
}
if quiet.Presentation.Output.State != DeliverySuppressed || quiet.Presentation.Diagnostics.State != DeliverySuppressed {
t.Fatalf("quiet presentation = %#v", quiet.Presentation)
}
var verboseOut, verboseErr bytes.Buffer
verbose := app.Execute(context.Background(), []string{"run", "--verbose"}, Streams{Out: &verboseOut, Err: &verboseErr})
if verbose.Kind != OutcomeDomain || verbose.Status != 0 {
t.Fatalf("verbose outcome = %#v", verbose)
}
if verboseOut.String() != "result\n" || verboseErr.String() != "WARN: attention\n" {
t.Fatalf("verbose output = %q, diagnostics = %q", verboseOut.String(), verboseErr.String())
}
if verbose.Presentation.Output.State != DeliveryComplete || verbose.Presentation.Diagnostics.State != DeliveryComplete {
t.Fatalf("verbose presentation = %#v", verbose.Presentation)
}
if len(reporters) != 2 || reporters[0] == nil || reporters[1] == nil || reporters[0] == reporters[1] {
t.Fatalf("invocation reporters = %#v", reporters)
}
if _, err := reporters[0].Output().Write([]byte("late")); !errors.Is(err, ErrReporterClosed) {
t.Fatalf("write through finalized reporter error = %v", err)
}
}
func TestExecuteQuietUsageKeepsAliasGuidanceVisible(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example"}, command, Alias("r", "run"))
if err != nil {
t.Fatal(err)
}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"r", "--quiet", "--unknown"}, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeUsage || outcome.Status != 2 {
t.Fatalf("outcome = %#v", outcome)
}
if output.Len() != 0 {
t.Fatalf("usage output = %q", output.String())
}
if got := diagnostics.String(); !strings.Contains(got, "ERROR: unknown flag") || !strings.Contains(got, "Usage: Example r") || !strings.Contains(got, "Alias for: Example run") {
t.Fatalf("usage diagnostics = %q", got)
}
if outcome.Presentation.Diagnostics.State != DeliveryComplete {
t.Fatalf("diagnostic presentation = %#v", outcome.Presentation.Diagnostics)
}
}
func TestUnresolvedRoutesUseReportingAliasesForTheirParseContext(t *testing.T) {
calls := 0
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
NewInput: func() struct{} { calls++; return struct{}{} },
Run: func(Invocation, struct{}) Outcome { calls++; return Success() },
})
app, err := New(Identity{Name: "Example"}, Group("group", "", Group("nested", "", command)),
FlagAlias("-d", "--debug"), FlagAlias("-q", "--quiet"),
FlagAlias("-x", "--quiet"), FlagAliasFor("group nested", "-x", "--debug"))
if err != nil {
t.Fatal(err)
}
for _, path := range [][]string{{"missing"}, {"group", "missing"}, {"group", "nested", "missing"}} {
t.Run(strings.Join(path, "/"), func(t *testing.T) {
var canonicalDiagnostics, aliasDiagnostics bytes.Buffer
canonical := app.Execute(context.Background(), append(append([]string(nil), path...), "--debug"), Streams{Err: &canonicalDiagnostics})
alias := app.Execute(context.Background(), append(append([]string(nil), path...), "-d"), Streams{Err: &aliasDiagnostics})
if canonical.Kind != OutcomeUsage || alias.Kind != OutcomeUsage || canonical.Status != alias.Status || canonical.Explanation.Message != alias.Explanation.Message {
t.Fatalf("canonical=%#v alias=%#v", canonical, alias)
}
for _, text := range []string{canonicalDiagnostics.String(), aliasDiagnostics.String()} {
if !strings.Contains(text, "DEBUG:") || !strings.Contains(text, "ERROR:") || strings.Contains(text, "unknown shorthand") || strings.Contains(text, "Alias for:") {
t.Fatalf("diagnostics=%q", text)
}
}
route := resolveEnteredRoute(path, app.routeSnapshot())
if route.kind != routeUnknown || len(route.canonicalPath) != 0 {
t.Fatalf("invalid route acquired canonical identity: %#v", route)
}
var quiet bytes.Buffer
got := app.Execute(context.Background(), append(append([]string(nil), path...), "-q", "--unknown"), Streams{Err: &quiet})
if got.Kind != OutcomeUsage || !strings.Contains(quiet.String(), "ERROR: unknown flag") || strings.Contains(quiet.String(), "DEBUG:") {
t.Fatalf("quiet alias outcome=%#v diagnostics=%q", got, quiet.String())
}
})
}
var scoped bytes.Buffer
got := app.Execute(context.Background(), []string{"group", "nested", "missing", "-x"}, Streams{Err: &scoped})
if got.Kind != OutcomeUsage || !strings.Contains(scoped.String(), "DEBUG:") {
t.Fatalf("scoped precedence outcome=%#v diagnostics=%q", got, scoped.String())
}
if calls != 0 {
t.Fatalf("invalid routes ran factories or handlers %d times", calls)
}
}
type executeReportingFailureWriter struct{ err error }
func (w executeReportingFailureWriter) Write([]byte) (int, error) { return 0, w.err }
func TestExecuteFinalizesEarlyAndIgnoredWriterFailures(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(inv Invocation, _ struct{}) Outcome {
_, _ = inv.Reporter().Output().Write([]byte("report"))
return Success()
},
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
writeErr := errors.New("result destination failed")
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run"}, Streams{
Out: executeReportingFailureWriter{err: writeErr}, Err: &diagnostics,
})
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
t.Fatalf("ignored output failure outcome = %#v", outcome)
}
if !strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output") {
t.Fatalf("fallback diagnostic = %q", diagnostics.String())
}
diagnostics.Reset()
outcome = app.Execute(nil, []string{"run"}, Streams{Err: &diagnostics})
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil {
t.Fatalf("nil context outcome = %#v", outcome)
}
if !strings.Contains(diagnostics.String(), "ERROR: invocation context must not be nil\n") {
t.Fatalf("nil context diagnostic = %q", diagnostics.String())
}
diagnostics.Reset()
var nilApp *App
outcome = nilApp.Execute(context.Background(), []string{"run"}, Streams{Err: &diagnostics})
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil {
t.Fatalf("nil app outcome = %#v", outcome)
}
if !strings.Contains(diagnostics.String(), "ERROR: cannot execute commands on a nil app\n") {
t.Fatalf("nil app diagnostic = %q", diagnostics.String())
}
}

282
flag_aliases.go Normal file
View File

@@ -0,0 +1,282 @@
package commandkit
import (
"fmt"
"sort"
"strings"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
type resolvedFlagAlias struct {
source capturedFlagSpelling
target capturedFlagSpelling
}
type resolvedFlagAliases struct {
bySpelling map[string]resolvedFlagAlias
}
type flagAliasMapping struct {
target capturedFlagSpelling
scoped bool
}
type flagAliasContext struct {
path commandpath.Path
canonical map[string]capturedFlagSpelling
discovery bool
}
func (aliases resolvedFlagAliases) neutralRecords() []commandmeta.FlagAlias {
keys := make([]string, 0, len(aliases.bySpelling))
for key := range aliases.bySpelling {
keys = append(keys, key)
}
sort.Strings(keys)
records := make([]commandmeta.FlagAlias, 0, len(keys))
for _, key := range keys {
alias := aliases.bySpelling[key]
records = append(records, commandmeta.FlagAlias{Source: alias.source.token(), Target: alias.target.token()})
}
return records
}
func (spelling capturedFlagSpelling) token() string {
if spelling.short {
return "-" + spelling.name
}
return "--" + spelling.name
}
func compileFlagAliases(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
Resolve(commandpath.Path) commandpath.Path
}) (map[string]resolvedFlagAliases, map[string]resolvedFlagAliases, error) {
canonicalNodes := indexCanonicalNodes(roots)
global := make(map[string]flagAliasMapping)
scoped := make(map[string]map[string]flagAliasMapping)
for _, rule := range rules {
record := rule.record
switch record.kind {
case globalFlagAliasRule:
source := flagSpellingKey(record.sourceFlag)
global[source] = flagAliasMapping{target: record.targetFlag}
case scopedFlagAliasRule:
scope := record.scopePath.Key()
if canonicalNodes[scope] == nil {
return nil, nil, fmt.Errorf("flag alias scope %q is not a canonical command path", record.scopePath.String())
}
if scoped[scope] == nil {
scoped[scope] = make(map[string]flagAliasMapping)
}
source := flagSpellingKey(record.sourceFlag)
scoped[scope][source] = flagAliasMapping{target: record.targetFlag, scoped: true}
}
}
canonicalContexts := make(map[string]flagAliasContext, len(canonicalNodes)+1)
rootPath := commandpath.Path(nil)
canonicalContexts[rootPath.Key()] = flagAliasContext{path: rootPath, canonical: helpFlagSpellings()}
for key, node := range canonicalNodes {
canonicalContexts[key] = flagAliasContext{path: node.path.Clone(), canonical: canonicalFlagSpellings(node.command)}
}
discoveryContexts := discoverFlagContexts(rules, roots, aliases, canonicalNodes)
canonicalKeys := sortedContextKeys(canonicalContexts)
resolvedCanonical := make(map[string]resolvedFlagAliases, len(canonicalContexts))
for _, key := range canonicalKeys {
context := canonicalContexts[key]
mappings := cloneFlagMappings(global)
for source, mapping := range scoped[key] {
mappings[source] = mapping
}
resolved, err := resolveFlagAliases(context, mappings)
if err != nil {
return nil, nil, err
}
resolvedCanonical[key] = resolved
}
discoveryKeys := sortedContextKeys(discoveryContexts)
resolvedDiscovery := make(map[string]resolvedFlagAliases, len(discoveryContexts))
for _, key := range discoveryKeys {
context := discoveryContexts[key]
resolved, err := resolveFlagAliases(context, cloneFlagMappings(global))
if err != nil {
return nil, nil, err
}
resolvedDiscovery[key] = resolved
}
return resolvedCanonical, resolvedDiscovery, nil
}
func indexCanonicalNodes(roots map[string]*canonicalNode) map[string]*canonicalNode {
indexed := make(map[string]*canonicalNode)
var add func(*canonicalNode)
add = func(node *canonicalNode) {
indexed[node.path.Key()] = node
for _, child := range node.children {
add(child)
}
}
for _, root := range roots {
add(root)
}
return indexed
}
func helpFlagSpellings() map[string]capturedFlagSpelling {
flags := make(map[string]capturedFlagSpelling, 2+len(commandmeta.ReportingFlags()))
for _, spelling := range []capturedFlagSpelling{{name: "help"}, {name: "h", short: true}} {
flags[flagSpellingKey(spelling)] = spelling
}
for _, metadata := range commandmeta.ReportingFlags() {
spelling := capturedFlagSpelling{name: metadata.Name}
flags[flagSpellingKey(spelling)] = spelling
}
return flags
}
func canonicalFlagSpellings(command Command) map[string]capturedFlagSpelling {
flags := helpFlagSpellings()
if command.record.payload == nil {
return flags
}
for _, metadata := range command.record.payload.bridgeFlags() {
long := capturedFlagSpelling{name: metadata.Name}
flags[flagSpellingKey(long)] = long
if metadata.Short != "" {
short := capturedFlagSpelling{name: metadata.Short, short: true}
flags[flagSpellingKey(short)] = short
}
}
return flags
}
func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
Resolve(commandpath.Path) commandpath.Path
}, canonicalNodes map[string]*canonicalNode) map[string]flagAliasContext {
commandSources := make(map[string]struct{})
prefixes := make(map[string]commandpath.Path)
for _, rule := range rules {
if rule.record.kind == commandAliasRule {
commandSources[rule.record.sourcePath.Key()] = struct{}{}
}
}
for _, rule := range rules {
if rule.record.kind != commandAliasRule {
continue
}
for length := 1; length < len(rule.record.sourcePath); length++ {
prefix := rule.record.sourcePath[:length].Clone()
key := prefix.Key()
if canonicalNodes[key] != nil {
continue
}
if _, exactAlias := commandSources[key]; exactAlias {
continue
}
resolved := aliases.Resolve(prefix)
trace := traceCanonicalPath(roots, resolved)
if trace.exact != nil || (trace.operandOwner != nil && acceptsOperandPrefix(trace.operandOwner.command, len(resolved)-len(trace.operandOwner.path))) {
continue
}
prefixes[key] = prefix
}
}
contexts := make(map[string]flagAliasContext, len(prefixes))
for key, path := range prefixes {
contexts[key] = flagAliasContext{path: path, canonical: helpFlagSpellings(), discovery: true}
}
return contexts
}
func cloneFlagMappings(mappings map[string]flagAliasMapping) map[string]flagAliasMapping {
cloned := make(map[string]flagAliasMapping, len(mappings))
for source, mapping := range mappings {
cloned[source] = mapping
}
return cloned
}
func sortedContextKeys(contexts map[string]flagAliasContext) []string {
keys := make([]string, 0, len(contexts))
for key := range contexts {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func resolveFlagAliases(context flagAliasContext, mappings map[string]flagAliasMapping) (resolvedFlagAliases, error) {
result := resolvedFlagAliases{bySpelling: make(map[string]resolvedFlagAlias)}
sources := make([]string, 0, len(mappings))
for source := range mappings {
sources = append(sources, source)
}
sort.Strings(sources)
for _, sourceKey := range sources {
mapping := mappings[sourceKey]
source := spellingFromKey(sourceKey)
target, active, err := resolveFlagTarget(source, mapping.target, mappings, context.canonical)
if err != nil {
return resolvedFlagAliases{}, fmt.Errorf("%s: %w", flagContextDescription(context), err)
}
if !active {
if mapping.scoped {
return resolvedFlagAliases{}, fmt.Errorf("scoped flag alias %s has no canonical destination in %s", source.token(), flagContextDescription(context))
}
continue
}
if _, collision := context.canonical[sourceKey]; collision {
return resolvedFlagAliases{}, fmt.Errorf("flag alias %s conflicts with a canonical spelling in %s", source.token(), flagContextDescription(context))
}
result.bySpelling[sourceKey] = resolvedFlagAlias{source: source, target: target}
}
return result, nil
}
func resolveFlagTarget(source, target capturedFlagSpelling, mappings map[string]flagAliasMapping, canonical map[string]capturedFlagSpelling) (capturedFlagSpelling, bool, error) {
visited := map[string]int{flagSpellingKey(source): 0}
chain := []capturedFlagSpelling{source}
current := target
for {
key := flagSpellingKey(current)
if endpoint, exists := canonical[key]; exists {
return endpoint, true, nil
}
if start, exists := visited[key]; exists {
cycle := append(append([]capturedFlagSpelling(nil), chain[start:]...), current)
spellings := make([]string, 0, len(cycle))
for _, spelling := range cycle {
spellings = append(spellings, spelling.token())
}
return capturedFlagSpelling{}, false, fmt.Errorf("flag alias cycle: %s", strings.Join(spellings, " → "))
}
visited[key] = len(chain)
chain = append(chain, current)
next, exists := mappings[key]
if !exists {
return capturedFlagSpelling{}, false, nil
}
current = next.target
}
}
func spellingFromKey(key string) capturedFlagSpelling {
if strings.HasPrefix(key, "short:") {
return capturedFlagSpelling{name: strings.TrimPrefix(key, "short:"), short: true}
}
return capturedFlagSpelling{name: strings.TrimPrefix(key, "long:")}
}
func flagContextDescription(context flagAliasContext) string {
if context.discovery {
return fmt.Sprintf("alias prefix %q", context.path.String())
}
if len(context.path) == 0 {
return "application root"
}
return fmt.Sprintf("command %q", context.path.String())
}

297
flag_aliases_test.go Normal file
View File

@@ -0,0 +1,297 @@
package commandkit
import (
"strings"
"testing"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
func flagAliasCommand(name string, flags ...Flag[struct{}]) Command {
return NewCommand(CommandSpec[struct{}]{
Name: name, Summary: name, Flags: flags,
Run: func(Invocation, struct{}) Outcome { return Success() },
})
}
func stringFlag(name, short string) Flag[struct{}] {
return ValueFlag(FlagMeta{Name: name, Short: short}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(*struct{}, Supplied[string]) {},
)
}
func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
inspect := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("format", "f"))
show := flagAliasCommand("show", stringFlag("color", "c"), stringFlag("format", "f"))
group := Group("Tools", "tools", show)
app, err := New(Identity{Name: "Example"},
inspect,
group,
Alias("look", "inspect"),
FlagAlias("--colour", "--hue"),
FlagAlias("--hue", "--color"),
FlagAlias("-x", "--color"),
FlagAlias("--tint", "-c"),
FlagAliasFor("Tools", "--group-help", "--help"),
FlagAliasFor("inspect", "--hue", "--format"),
FlagAliasFor("inspect", "--local", "--format"),
)
if err != nil {
t.Fatal(err)
}
inspectAliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
for source, target := range map[string]string{
"--colour": "--format",
"--hue": "--format",
"-x": "--color",
"--tint": "-c",
"--local": "--format",
} {
alias, ok := inspectAliases[flagSpellingKey(capturedSpelling(source))]
if !ok || alias.target.token() != target {
t.Errorf("inspect alias %s = (%s, %v), want %s", source, alias.target.token(), ok, target)
}
}
groupAliases := app.flagAliases[commandpath.Path{"Tools"}.Key()].bySpelling
if _, active := groupAliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})]; active {
t.Error("global alias was active on a group without its destination flag")
}
if _, inherited := groupAliases[flagSpellingKey(capturedFlagSpelling{name: "local"})]; inherited {
t.Fatal("command-scoped alias was inherited by its parent group")
}
if _, inherited := app.flagAliases[commandpath.Path{"Tools", "show"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "local"})]; inherited {
t.Fatal("command-scoped alias was inherited by a different command")
}
groupHelp := flagSpellingKey(capturedFlagSpelling{name: "group-help"})
if got := groupAliases[groupHelp].target.token(); got != "--help" {
t.Fatalf("group-scoped help alias target = %s, want --help", got)
}
if _, inherited := app.flagAliases[commandpath.Path{"Tools", "show"}.Key()].bySpelling[groupHelp]; inherited {
t.Fatal("group-scoped alias was inherited by its child command")
}
}
func TestFlagAliasDiscoveryContextsUseGlobalReportingAliases(t *testing.T) {
show := flagAliasCommand("show")
app, err := New(Identity{Name: "Example"},
Group("Tools", "tools", show),
Alias("shortcut show", "Tools show"),
FlagAlias("--hlep", "--help"),
FlagAlias("--silent", "--quiet"),
FlagAlias("--chatty", "--verbose"),
FlagAlias("--trace", "--debug"),
FlagAlias("--show-progress", "--progress"),
FlagAliasFor("Tools show", "--local-help", "--help"),
)
if err != nil {
t.Fatal(err)
}
globalHelp := flagSpellingKey(capturedFlagSpelling{name: "hlep"})
if got := app.flagAliases[commandpath.Path(nil).Key()].bySpelling[globalHelp].target.token(); got != "--help" {
t.Fatalf("root --hlep target = %s, want --help", got)
}
if got := app.flagAliases[commandpath.Path{"Tools"}.Key()].bySpelling[globalHelp].target.token(); got != "--help" {
t.Fatalf("group --hlep target = %s, want --help", got)
}
discovery := app.discoveryFlags[commandpath.Path{"shortcut"}.Key()].bySpelling
if got := discovery[globalHelp].target.token(); got != "--help" {
t.Fatalf("discovery --hlep target = %s, want --help", got)
}
for label, aliases := range map[string]resolvedFlagAliases{
"root": app.flagAliases[commandpath.Path(nil).Key()],
"group": app.flagAliases[commandpath.Path{"Tools"}.Key()],
"command": app.flagAliases[commandpath.Path{"Tools", "show"}.Key()],
"discovery": app.discoveryFlags[commandpath.Path{"shortcut"}.Key()],
} {
for source, target := range map[string]string{
"silent": "quiet",
"chatty": "verbose",
"trace": "debug",
"show-progress": "progress",
} {
key := flagSpellingKey(capturedFlagSpelling{name: source})
if got := aliases.bySpelling[key].target.token(); got != "--"+target {
t.Errorf("%s --%s target = %s, want --%s", label, source, got, target)
}
}
}
if _, inherited := discovery[flagSpellingKey(capturedFlagSpelling{name: "local-help"})]; inherited {
t.Fatal("discovery context inherited a command-scoped alias")
}
}
func TestReportingFlagsAreReservedAndAcceptApplicationShortAliases(t *testing.T) {
for _, name := range []string{"quiet", "verbose", "debug", "progress"} {
command := flagAliasCommand("reserved", stringFlag(name, ""))
if len(command.record.issues) == 0 {
t.Errorf("capturing --%s did not retain a declaration error", name)
}
app, err := New(Identity{Name: "Example"}, flagAliasCommand("safe"))
if err != nil {
t.Fatal(err)
}
snapshot := app.routeSnapshot()
if err := app.Register(command, Alias("reserved-shortcut", "reserved")); err == nil || !strings.Contains(err.Error(), "reserved for reporting") {
t.Errorf("registering --%s returned %v, want a retained reporting reservation error", name, err)
}
if app.routeSnapshot() != snapshot || app.commands["reserved"] != nil || app.commands["safe"] == nil || len(app.routingRules) != 0 {
t.Fatalf("failed registration of --%s changed the installed commands", name)
}
}
declarations := []Declaration{flagAliasCommand("run")}
for short, target := range map[string]string{"q": "quiet", "v": "verbose", "d": "debug"} {
declarations = append(declarations, FlagAlias("-"+short, "--"+target))
}
if _, err := New(Identity{Name: "Example"}, declarations...); err != nil {
t.Fatalf("application short aliases for reporting flags were rejected: %v", err)
}
}
func TestReportingFlagSpellingsConflictWithAliasSources(t *testing.T) {
for _, name := range []string{"quiet", "verbose", "debug", "progress"} {
if _, err := New(Identity{Name: "Example"}, FlagAlias("--"+name, "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") {
t.Errorf("alias source --%s returned %v, want a canonical spelling collision", name, err)
}
}
if _, err := New(Identity{Name: "Example"}, Group("tools", "", flagAliasCommand("show")), FlagAliasFor("tools", "--quiet", "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") {
t.Errorf("group-scoped canonical reporting spelling returned %v, want a collision", err)
}
}
func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testing.T) {
app, err := New(Identity{Name: "Example"},
flagAliasCommand("run", stringFlag("format", "f")),
Group("tools", "", flagAliasCommand("show")),
)
if err != nil {
t.Fatal(err)
}
var check func(*commandmeta.Command)
check = func(node *commandmeta.Command) {
if len(node.CoreFlags) != 4 {
t.Errorf("%s core reporting flags = %#v", node.Name, node.CoreFlags)
}
for _, flag := range node.Flags {
if commandmeta.IsReportingFlag(flag.Name) {
t.Errorf("%s reporting flag %q was added to consumer bindings", node.Name, flag.Name)
}
}
for _, child := range node.Children {
check(child)
}
}
check(app.routeSnapshot().metadata)
}
func TestFlagAliasDuplicatesAndScopesAreDeterministic(t *testing.T) {
command := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("format", "f"))
duplicate := FlagAlias("--colour", "--color")
app, err := New(Identity{Name: "Example"}, command, flagAliasCommand("plain", stringFlag("color", "c")), duplicate, FlagAlias("--colour", "--color"),
FlagAliasFor("inspect", "--colour", "--format"))
if err != nil {
t.Fatalf("identical duplicates and scoped override were rejected: %v", err)
}
if got := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--format" {
t.Fatalf("scoped alias target = %s, want --format", got)
}
if got := app.flagAliases[commandpath.Path{"plain"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" {
t.Fatalf("effective global alias target = %s, want --color", got)
}
for name, rules := range map[string][]RoutingRule{
"competing global targets": {
FlagAlias("--colour", "--color"),
FlagAlias("--colour", "--format"),
},
"competing scoped targets": {
FlagAliasFor("inspect", "--colour", "--color"),
FlagAliasFor("inspect", "--colour", "--format"),
},
"unknown scope": {FlagAliasFor("missing", "--colour", "--color")},
"missing scoped destination": {FlagAliasFor("inspect", "--colour", "--missing")},
} {
t.Run(name, func(t *testing.T) {
declarations := []Declaration{command}
for _, rule := range rules {
declarations = append(declarations, rule)
}
if _, err := New(Identity{Name: "Example"}, declarations...); err == nil {
t.Fatal("New() accepted invalid flag alias rules")
}
})
}
}
func TestFlagAliasCyclesIdentifyParticipatingSpellings(t *testing.T) {
for name, declarations := range map[string][]Declaration{
"global": {
FlagAlias("--first", "--second"),
FlagAlias("--second", "--first"),
},
"scoped": {
flagAliasCommand("inspect"),
FlagAliasFor("inspect", "-a", "--second"),
FlagAliasFor("inspect", "--second", "-a"),
},
} {
t.Run(name, func(t *testing.T) {
_, err := New(Identity{Name: "Example"}, declarations...)
if err == nil {
t.Fatal("New() accepted a flag alias cycle")
}
message := err.Error()
for _, spelling := range []string{"--first", "--second"} {
if name == "scoped" {
continue
}
if !strings.Contains(message, spelling) {
t.Errorf("cycle error %q does not identify %s", message, spelling)
}
}
if name == "scoped" && (!strings.Contains(message, "-a") || !strings.Contains(message, "--second")) {
t.Errorf("scoped cycle error %q does not identify both spellings", message)
}
})
}
}
func TestInactiveGlobalAliasesActivateAtomicallyWhenDestinationAppears(t *testing.T) {
command := flagAliasCommand("inspect", stringFlag("color", "c"))
app, err := New(Identity{Name: "Example"}, command,
FlagAlias("--color", "--missing"),
FlagAlias("--colour", "--color"),
)
if err != nil {
t.Fatal(err)
}
aliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
if _, active := aliases[flagSpellingKey(capturedFlagSpelling{name: "color"})]; active {
t.Fatal("global alias with an unavailable destination became active")
}
if got := aliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" {
t.Fatalf("--colour endpoint = %s, want canonical --color", got)
}
snapshot := app.routeSnapshot()
commands := len(app.commands)
rules := len(app.routingRules)
originalAlias := aliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})]
if err := app.Register(flagAliasCommand("later", stringFlag("color", "c"), stringFlag("missing", ""))); err == nil {
t.Fatal("Register() accepted a newly active global source collision")
}
currentAlias := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})]
if app.routeSnapshot() != snapshot || len(app.commands) != commands || len(app.routingRules) != rules || currentAlias != originalAlias {
t.Fatal("failed registration changed the existing command or routing state")
}
}
func capturedSpelling(token string) capturedFlagSpelling {
issues := []error(nil)
return captureFlagSpelling("test", token, &issues)
}

View File

@@ -90,7 +90,7 @@ func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error {
func classifyInputError(err error) Outcome {
if errors.Is(err, ErrFlagWiring) {
return Failure(err)
return Failure(err.Error()).WithCause(err)
}
return Usage(err)
return Usage(err.Error()).WithCause(err)
}

63
help_view.go Normal file
View File

@@ -0,0 +1,63 @@
package commandkit
import (
"sort"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
func (a *App) helpView(route routeResult, snapshot *routingSnapshot) commandmeta.HelpView {
view := commandmeta.HelpView{
EnteredPath: clonePath(route.enteredPath),
CanonicalPath: clonePath(route.canonicalPath),
Discovery: route.kind == routeDiscovery,
CoreFlags: commandmeta.ReportingFlags(),
}
if snapshot == nil {
return view
}
view.CommandAliases = routeHelpAliases(snapshot, route)
if view.Discovery {
view.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords()
return view
}
view.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
return view
}
func clonePath(path commandpath.Path) []string {
return append([]string(nil), path...)
}
func routeHelpAliases(snapshot *routingSnapshot, route routeResult) []commandmeta.CommandAlias {
if route.kind != routeDiscovery && len(route.canonicalPath) == 0 {
return append([]commandmeta.CommandAlias(nil), snapshot.commandAliases...)
}
bySource := make(map[string]commandmeta.CommandAlias)
for _, prefix := range snapshot.aliases.ResolutionPaths(route.enteredPath) {
for _, alias := range snapshot.commandAliases {
source := commandpath.FromTokens(alias.Source)
if len(source) <= len(prefix) || !source.HasPrefix(prefix) {
continue
}
suffix := source[len(prefix):]
entered := append(route.enteredPath.Clone(), suffix...)
target := snapshot.aliases.Resolve(entered)
if traceCanonicalPath(snapshot.canonical, target).exact == nil {
continue
}
bySource[suffix.Key()] = commandmeta.CommandAlias{Source: clonePath(suffix), Target: clonePath(target)}
}
}
keys := make([]string, 0, len(bySource))
for key := range bySource {
keys = append(keys, key)
}
sort.Strings(keys)
result := make([]commandmeta.CommandAlias, 0, len(keys))
for _, key := range keys {
result = append(result, bySource[key])
}
return result
}

View File

@@ -18,8 +18,11 @@ type Identity struct {
Revision string
}
// Streams contains the output destinations associated with a command
// invocation.
// Streams contains the caller-owned output destinations associated with an
// invocation. Out receives substantive results; Err receives messages,
// terminal explanations, usage guidance, and CommandKit diagnostics. Nil
// destinations are replaced with io.Discard. CommandKit does not close or flush
// supplied writers.
type Streams struct {
// Out receives help and command reports.
Out io.Writer
@@ -27,11 +30,41 @@ type Streams struct {
Err io.Writer
}
// Invocation provides execution context and output streams to a command
// handler.
func normalizeStreams(streams Streams) Streams {
if streams.Out == nil {
streams.Out = io.Discard
}
if streams.Err == nil {
streams.Err = io.Discard
}
return streams
}
// Invocation provides execution context and invocation-scoped reporting to a
// command handler. Execute finalizes its reporter before returning. For command
// aliases, EnteredPath and CanonicalPath expose the route the caller used and
// the selected canonical command path. Both omit the executable name, flags,
// and operands.
type Invocation struct {
// Context carries caller cancellation and request-scoped values.
Context context.Context
// Streams are the caller-selected output destinations.
Streams Streams
reporter *Reporter
enteredPath string
canonicalPath string
}
// Reporter returns the reporting facility supplied for this invocation. A
// manually constructed Invocation has no reporter.
func (inv Invocation) Reporter() *Reporter { return inv.reporter }
// EnteredPath returns the command path used to invoke the selected command,
// excluding the executable name, flags, and operands. It is empty for an
// Invocation that was not created by command execution.
func (inv Invocation) EnteredPath() string { return inv.enteredPath }
// CanonicalPath returns the selected command's canonical path after resolving
// aliases, excluding the executable name, flags, and operands. It equals
// EnteredPath for a direct canonical invocation and is empty for an Invocation
// that was not created by command execution.
func (inv Invocation) CanonicalPath() string { return inv.canonicalPath }

View File

@@ -192,14 +192,14 @@ func TestFromConfigKitCompareRetainsSideDiagnosticsAndLoadError(t *testing.T) {
if !errors.As(gotErr, &typed) || typed != loadErr {
t.Fatalf("load error type not preserved: %#v", typed)
}
if len(acquired.Diagnostics) != 2 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || acquired.Diagnostics[1].Severity != commandkit.SeverityError {
t.Fatalf("retained diagnostics = %#v", acquired.Diagnostics)
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || len(acquired.Explanation.Details) != 1 {
t.Fatalf("retained diagnostics/explanation = %#v / %#v", acquired.Diagnostics, acquired.Explanation)
}
var leftText, rightText bytes.Buffer
if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
t.Fatal(err)
}
if err := acquired.Diagnostics[1].Write(&rightText); err != nil {
if err := acquired.Explanation.Details[0](&rightText); err != nil {
t.Fatal(err)
}
if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) {
@@ -231,8 +231,15 @@ func TestFromConfigKitCompareStopsAfterLeftLoadFailure(t *testing.T) {
t.Fatalf("left failure/right load = %v / %v", err, rightCalled)
}
var typed *configkit.LoadError
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
t.Fatalf("left failure identity/diagnostics = %v / %#v", err, acquired.Diagnostics)
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Explanation.Details) != 1 {
t.Fatalf("left failure identity/explanation = %v / %#v", err, acquired.Explanation)
}
var terminal bytes.Buffer
if err := acquired.Explanation.Details[0](&terminal); err != nil {
t.Fatal(err)
}
if !strings.Contains(terminal.String(), `Configuration scope "app" left:`) || !strings.Contains(terminal.String(), `"left-source"`) {
t.Fatalf("left terminal context lost side/source identity: %q", terminal.String())
}
}

View File

@@ -33,53 +33,58 @@ func FromConfigKit[I, S, T any](
scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) {
result, err := load(ctx, request.Selection, input, state)
acquired := commandkit.Acquired[View]{}
if result != nil {
acquired.Value = View{Effective: result, Sources: result, Explanation: result}
acquired.Diagnostics, acquired.Explanation = configKitDiagnostics(name, result.Sources(), result.Diagnostics())
}
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) && loadError != nil {
return commandkit.Acquired[View]{Diagnostics: configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err
warnings, explanation := configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())
acquired.Diagnostics = append(acquired.Diagnostics, warnings...)
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, explanation)
}
return commandkit.Acquired[View]{}, err
return acquired, err
}
if result == nil {
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
}
sources := result.Sources()
return commandkit.Acquired[View]{
Value: View{Effective: result, Sources: result, Explanation: result},
Diagnostics: configKitDiagnostics(name, sources, result.Diagnostics()),
}, nil
return acquired, nil
}
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
if err := ctx.Err(); err != nil {
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err)
}
left, leftDiagnostics, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
acquired := commandkit.Acquired[[]configkit.Difference]{}
left, leftDiagnostics, leftExplanation, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
acquired.Diagnostics = append(acquired.Diagnostics, leftDiagnostics...)
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, leftExplanation)
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
return acquired, err
}
if err := ctx.Err(); err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
return acquired, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
}
right, rightDiagnostics, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
diagnostics = append(diagnostics, rightDiagnostics...)
right, rightDiagnostics, rightExplanation, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
acquired.Diagnostics = append(acquired.Diagnostics, rightDiagnostics...)
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, rightExplanation)
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
return acquired, err
}
if err := ctx.Err(); err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
return acquired, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
}
differences, err := configkit.Diff(left, right)
acquired.Value, err = configkit.Diff(left, right)
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
return acquired, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
}
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil
return acquired, nil
}
return scope
}
@@ -90,67 +95,110 @@ func loadComparisonSide[I, S, T any](
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
request ComparisonSide[I],
state S,
) (*configkit.Result[T], []commandkit.Diagnostic, error) {
) (*configkit.Result[T], []commandkit.Diagnostic, commandkit.Explanation, error) {
result, err := load(ctx, request.Selection, request.Input, state)
var diagnostics []commandkit.Diagnostic
var explanation commandkit.Explanation
if result != nil {
warnings, terminal := configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics())
diagnostics = append(diagnostics, warnings...)
explanation = appendConfigKitExplanation(explanation, terminal)
}
if err != nil {
var loadError *configkit.LoadError
var diagnostics []commandkit.Diagnostic
if errors.As(err, &loadError) && loadError != nil {
diagnostics = configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
warnings, terminal := configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
diagnostics = append(diagnostics, warnings...)
explanation = appendConfigKitExplanation(explanation, terminal)
}
return nil, diagnostics, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
return result, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
}
if result == nil {
return nil, nil, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
return nil, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
}
return result, configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics()), nil
return result, diagnostics, explanation, nil
}
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
}
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
}
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
if len(records) == 0 {
return nil
return nil, commandkit.Explanation{}
}
severity := commandkit.SeverityWarning
sources = append([]configkit.SourceInfo(nil), sources...)
records = cloneConfigKitDiagnostics(records)
warnings := make([]configkit.Diagnostic, 0, len(records))
terminal := make([]configkit.Diagnostic, 0, len(records))
for _, record := range records {
if record.Severity == configkit.SeverityError {
severity = commandkit.SeverityError
break
terminal = append(terminal, record)
} else {
warnings = append(warnings, record)
}
}
// Capture the upstream snapshots so later calls to Write do not depend on
// slices returned by the provider.
sources = append([]configkit.SourceInfo(nil), sources...)
records = append([]configkit.Diagnostic(nil), records...)
return []commandkit.Diagnostic{{
Severity: severity,
Write: func(writer io.Writer) error {
if err := writeDiagnosticText(writer, heading); err != nil {
if len(warnings) == 0 {
return nil, explanationWithConfigKitRecords(heading, sources, terminal)
}
warning := commandkit.Diagnostic{
Severity: commandkit.SeverityWarning,
Write: configKitDiagnosticWriter(heading, sources, warnings),
}
return []commandkit.Diagnostic{warning}, explanationWithConfigKitRecords(heading, sources, terminal)
}
func explanationWithConfigKitRecords(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Explanation {
if len(records) == 0 {
return commandkit.Explanation{}
}
return commandkit.Explanation{Details: []commandkit.Detail{configKitDiagnosticWriter(heading, sources, records)}}
}
func configKitDiagnosticWriter(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Detail {
return func(writer io.Writer) error {
if err := writeDiagnosticText(writer, heading); err != nil {
return err
}
if len(sources) > 0 {
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
return err
}
if len(sources) > 0 {
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
for _, source := range sources {
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
if err := writeDiagnosticText(writer, line); err != nil {
return err
}
for _, source := range sources {
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
if err := writeDiagnosticText(writer, line); err != nil {
return err
}
}
}
return configkit.WriteDiagnostics(writer, records)
},
}}
}
return configkit.WriteDiagnostics(writer, records)
}
}
func cloneConfigKitDiagnostics(records []configkit.Diagnostic) []configkit.Diagnostic {
cloned := append([]configkit.Diagnostic(nil), records...)
for index := range cloned {
if records[index].Primary != nil {
primary := *records[index].Primary
cloned[index].Primary = &primary
}
cloned[index].Related = append([]configkit.Location(nil), records[index].Related...)
}
return cloned
}
func appendConfigKitExplanation(left, right commandkit.Explanation) commandkit.Explanation {
if left.Message == "" {
left.Message = right.Message
}
left.Details = append(left.Details, right.Details...)
return left
}
func writeDiagnosticText(writer io.Writer, value string) error {

View File

@@ -98,12 +98,13 @@ type Scope[I, S any] struct {
// Operations selects the standard handlers exposed for this scope.
Operations []Operation
// Acquire loads the selected configuration for one ordinary inspection
// invocation.
// invocation. It returns independent diagnostics and terminal context
// separately from the native value and optional operation error.
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
// Compare compares two selections for this scope using one shared state.
// Its Acquired result carries native difference records and diagnostics;
// returned errors retain their supported cause identity. It is required when
// Diff is declared in Operations.
// Its Acquired result carries native difference records, independent
// diagnostics, and terminal context; returned errors retain their supported
// cause identity. It is required when Diff is declared in Operations.
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
issues []error

View File

@@ -126,14 +126,20 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
}
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
type settings struct {
Name string `yaml:"name"`
}
_, loadErr := configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[settings]{Definition: configkit.Definition[settings]{}})
path := configkit.Keys("schema_version")
_, loadErr := configkit.Load(configkit.Bytes("bad source", []byte("{}\n")), configkit.Options[struct{}]{
Definition: configkit.Definition[struct{}]{Version: &configkit.VersionRule{
Path: path,
Check: func(_ configkit.Version, validation *configkit.Validation) error {
validation.Warning(path, "review schema compatibility")
return errors.New("private checker error")
},
}},
})
if loadErr == nil {
t.Fatal("fixture did not produce a ConfigKit load error")
}
scope := FromConfigKit[struct{}, struct{}, settings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[settings], error) {
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) {
return nil, loadErr
})
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
@@ -144,38 +150,121 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
if !errors.As(gotErr, &typed) {
t.Fatalf("load error type was not retained: %T", gotErr)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("failure diagnostics = %#v", acquired.Diagnostics)
}
var rendered bytes.Buffer
if err := acquired.Diagnostics[0].Write(&rendered); err != nil {
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
t.Fatalf("failure explanation = %#v", acquired.Explanation)
}
var warning, terminal bytes.Buffer
if err := acquired.Diagnostics[0].Write(&warning); err != nil {
t.Fatal(err)
}
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) {
t.Fatalf("diagnostic output lacks scope/source legend: %q", rendered.String())
if err := acquired.Explanation.Details[0](&terminal); err != nil {
t.Fatal(err)
}
if !strings.Contains(warning.String(), `Configuration scope "app":`) || !strings.Contains(warning.String(), `review schema compatibility`) {
t.Fatalf("warning output lost its scope or record: %q", warning.String())
}
if !strings.Contains(terminal.String(), `Configuration scope "app":`) || !strings.Contains(terminal.String(), `"bad source"`) || strings.Contains(terminal.String(), "review schema compatibility") {
t.Fatalf("terminal explanation lost native context or included an independent warning: %q", terminal.String())
}
if strings.Contains(terminal.String(), "private checker error") {
t.Fatalf("terminal explanation exposed the upstream cause: %q", terminal.String())
}
}
func TestFromConfigKitPreservesValueAndDiagnosticsAlongsideError(t *testing.T) {
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
loadErr := errors.New("loader returned a value with an error")
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
return result, loadErr
})
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
if gotErr != loadErr {
t.Fatalf("load error identity = %v, want %v", gotErr, loadErr)
}
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result {
t.Fatalf("acquired value facets were lost: %#v", acquired.Value)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("independent diagnostics = %#v", acquired.Diagnostics)
}
if len(acquired.Explanation.Details) != 0 {
t.Fatalf("warning-only result acquired terminal context: %#v", acquired.Explanation)
}
}
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
warning := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
warning, noExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
t.Fatalf("warning diagnostics = %#v", warning)
}
withError := configKitDiagnostics("app", nil, []configkit.Diagnostic{
if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
t.Fatalf("warning-only explanation = %#v", noExplanation)
}
withWarning, withExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{
{Severity: configkit.SeverityWarning, Message: "safe warning"},
{Severity: configkit.SeverityError, Message: "safe error"},
})
if withError[0].Severity != commandkit.SeverityError {
t.Fatalf("mixed diagnostics severity = %q", withError[0].Severity)
if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation)
}
if err := withError[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
if err := withWarning[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
}
if err := withExplanation.Details[0](shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("terminal short writer error = %v, want %v", err, io.ErrShortWrite)
}
wantErr := errors.New("writer failed")
if err := withError[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
if err := withWarning[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
t.Fatalf("writer error = %v, want %v", err, wantErr)
}
if err := withExplanation.Details[0](failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
t.Fatalf("terminal writer error = %v, want %v", err, wantErr)
}
}
func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.T) {
primary := &configkit.Location{SourceID: "primary-source", Line: 4, Column: 2}
related := []configkit.Location{{SourceID: "related-source", Line: 2, Column: 1}}
sources := []configkit.SourceInfo{
{ID: "primary-source", Label: "primary", Path: "primary.yml"},
{ID: "related-source", Label: "related", Path: "related.yml"},
}
records := []configkit.Diagnostic{{
Severity: configkit.SeverityError,
Path: configkit.Keys("name"),
Message: "native terminal context",
Primary: primary,
Related: related,
}}
_, explanation := configKitDiagnostics("app", sources, records)
primary.SourceID = "mutated-primary"
related[0].SourceID = "mutated-related"
sources[0].Path = "mutated.yml"
if len(explanation.Details) != 1 {
t.Fatalf("explanation = %#v", explanation)
}
var output bytes.Buffer
if err := explanation.Details[0](&output); err != nil {
t.Fatal(err)
}
for _, fragment := range []string{"primary.yml", "primary-source", "related-source", "native terminal context"} {
if !strings.Contains(output.String(), fragment) {
t.Errorf("terminal context %q omits %q", output.String(), fragment)
}
}
if strings.Contains(output.String(), "mutated-") || strings.Contains(output.String(), "mutated.yml") {
t.Fatalf("terminal renderer retained caller mutations: %q", output.String())
}
}
type shortWriter struct{}

View File

@@ -9,35 +9,28 @@ import (
func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
if plan.comparison == nil || plan.comparison.scope == nil {
return configOperationFailure(errors.New("configuration comparison was not prepared"), nil, false)
cause := errors.New("configuration comparison was not prepared")
return configFailure("Configuration comparison was not prepared.", commandkit.Explanation{}, cause)
}
selected := plan.comparison
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, nil, false)
return configFailure("Configuration comparison was canceled.", commandkit.Explanation{}, err)
}
compare := selected.scope.scope.Compare
if compare == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name), nil, false)
cause := fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name)
return configFailure("Configuration scope does not support comparison.", commandkit.Explanation{}, cause)
}
acquired, err := compare(invocation.Context, selected.request, state)
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
reporter := invocation.Reporter()
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure(fmt.Sprintf("Unable to compare configuration scope %q.", selected.scope.scope.Name), acquired.Explanation, err)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration comparison was canceled.", acquired.Explanation, err)
}
report, err := renderDiffReport(acquired.Value)
if err != nil {
return configOperationFailure(err, diagnostics, false)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
}
if err := writeConfigReport(invocation.Streams.Out, report); err != nil {
return configDeliveryFailure(err, diagnostics, true)
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
return renderDiffReport(acquired.Value)
})
}

View File

@@ -101,21 +101,30 @@ func TestConfigDiffRunsComparisonOnlyScopeAndRejectsUnsupportedScopeBeforeWork(t
func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) {
wantErr := errors.New("provider unavailable")
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error {
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "provider warning\n")
return err
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, fmt.Errorf("comparison setup: %w", wantErr)
return commandkit.Acquired[[]configkit.Difference]{
Diagnostics: []commandkit.Diagnostic{diagnostic},
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
_, err := io.WriteString(writer, "native comparison context\n")
return err
}}},
}, fmt.Errorf("comparison setup: %w", wantErr)
},
}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityError {
var output, diagnosticOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || output.Len() != 0 {
t.Fatalf("provider failure outcome/output = %#v / %q", outcome, output.String())
}
if got := diagnosticOutput.String(); !strings.Contains(got, "provider warning") || !strings.Contains(got, "native comparison context") || strings.Index(got, "provider warning") > strings.Index(got, "native comparison context") || strings.Contains(got, wantErr.Error()) {
t.Fatalf("provider messages and terminal context = %q", got)
}
}
func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
@@ -128,8 +137,8 @@ func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
},
}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 {
outcome := app.Execute(context.Background(), []string{"config", "diff", "--quiet"}, commandkit.Streams{Out: failingOutput{err: errors.New("quiet destination should not be called")}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || outcome.Presentation.Output.RenderError == nil || output.Len() != 0 {
t.Fatalf("report preparation failure outcome/output = %#v / %q", outcome, output.String())
}
}
@@ -146,8 +155,8 @@ func TestConfigDiffCancellationAfterAcquisitionRetainsDiagnosticsWithoutWriting(
}}}))
defer cancel()
var output bytes.Buffer
outcome := app.Execute(ctx, []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || outcome.ReportIncomplete || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityWarning {
outcome := app.Execute(ctx, []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || output.Len() != 0 {
t.Fatalf("cancelled comparison outcome/output = %#v / %q", outcome, output.String())
}
}
@@ -170,13 +179,36 @@ func TestConfigDiffOutputErrorsCarryIncompleteDeliveryOutcome(t *testing.T) {
} {
t.Run(test.name, func(t *testing.T) {
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, test.want) || !errors.Is(outcome.Cause, test.want) || !outcome.ReportIncomplete {
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("delivery failure outcome = %#v", outcome)
}
})
}
}
func TestConfigDiffDiagnosticDestinationFailureIsPresentationEvidence(t *testing.T) {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{
Diagnostics: []commandkit.Diagnostic{{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "provider warning\n")
return err
}}},
}, nil
},
}}}))
writeErr := errors.New("diagnostic destination failed")
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: failingOutput{err: writeErr}})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
t.Fatalf("diagnostic delivery failure outcome = %#v", outcome)
}
if output.Len() == 0 {
t.Fatal("healthy result destination did not receive the diff report")
}
}
func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
type input struct {
ID int

View File

@@ -1,6 +1,9 @@
// Package ckconfig provides CommandKit's optional ConfigKit integration. Its
// declarations install the standard config command family through the core
// commandkit package. Source reports preserve ConfigKit's destination,
// placement, and version-check evidence; importing ckconfig alone installs no
// commands.
// canonical Command declaration installs the standard config command family
// through the core commandkit package. Applications can install CommandKit
// RoutingRule declarations alongside it through commandkit.New or
// App.Register. Source reports preserve ConfigKit's destination, placement,
// and version-check evidence. Invocation reporting applies CommandKit's shared
// visibility and delivery policy; provider warnings remain independent of
// failure explanations. Importing ckconfig alone installs no commands.
package ckconfig

View File

@@ -4,6 +4,8 @@ Run the maintained example with:
```sh
go run . config show
go run . config show --config fixtures/before.yml --verbose
go run . config show --config fixtures/before.yml --quiet
go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml
go run . greet
go run . greet --count=0 --tag=first --tag second
@@ -15,3 +17,6 @@ omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
With no `--config`, application settings use the in-memory default. A supplied
configuration and overlay are passed to ConfigKit unchanged. The comparison
command loads the two local fixtures and reports `changes.greeting: Goodbye`.
The before fixture uses schema version 1, which ConfigKit reports as a provider
warning in verbose mode. Ordinary mode hides the warning while preserving the
effective YAML report; quiet mode suppresses that report.

View File

@@ -1 +1,3 @@
schema:
version: 2
greeting: Goodbye

View File

@@ -1 +1,3 @@
schema:
version: 1
greeting: Hello

View File

@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"io"
"os"
"strconv"
@@ -12,6 +13,9 @@ import (
)
type appConfiguration struct {
Schema struct {
Version int `yaml:"version"`
} `yaml:"schema"`
Greeting string `yaml:"greeting"`
}
@@ -22,12 +26,25 @@ type greetInput struct {
func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*configkit.Result[appConfiguration], error) {
source := configkit.Mapping("simple application settings", map[string]any{
"schema": map[string]any{"version": 2},
"greeting": "Hello",
})
if selection.Config.Set {
source = configkit.File(selection.Config.Value)
}
options := configkit.Options[appConfiguration]{}
options := configkit.Options[appConfiguration]{
Definition: configkit.Definition[appConfiguration]{
Version: &configkit.VersionRule{
Path: configkit.Keys("schema", "version"),
Check: func(version configkit.Version, validation *configkit.Validation) error {
if version.Text == "1" {
validation.Warning(configkit.Keys("schema", "version"), "schema version 1 is supported; upgrade to version 2 before the next example release")
}
return nil
},
},
},
}
if selection.Overlay.Set {
options.Overlay = selection.Overlay.Value
options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."}
@@ -50,9 +67,10 @@ func newApp() (*commandkit.App, error) {
}),
},
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
reporter := invocation.Reporter()
configuration, err := loadAppConfiguration(invocation.Context, ckconfig.Selection{})
if err != nil {
return commandkit.Failure(err)
return commandkit.Failure("Could not load application configuration.").WithCause(err)
}
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
if input.Count.Set {
@@ -62,9 +80,10 @@ func newApp() (*commandkit.App, error) {
if input.Tags.Set {
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
}
if _, err := fmt.Fprintf(invocation.Streams.Out, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags); err != nil {
return commandkit.Failure(err)
}
_ = reporter.RenderOutput(func(writer io.Writer) error {
_, err := fmt.Fprintf(writer, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags)
return err
})
return commandkit.Success()
},
})

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
@@ -42,7 +43,7 @@ func TestSimpleAppComparesFixturesAndKeepsDefaultCommands(t *testing.T) {
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "greeting: Hello\n" {
if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "greeting: Hello\nschema:\n version: 2\n" {
t.Fatalf("default show outcome/output = %#v / %q", outcome, output.String())
}
@@ -60,6 +61,73 @@ func TestSimpleLoaderDoesNotReplaceExplicitEmptyConfiguration(t *testing.T) {
}
}
func TestSimpleConfigurationReportingModes(t *testing.T) {
for _, test := range []struct {
name string
args []string
wantOutput string
wantDiagnostic string
quiet bool
}{
{
name: "ordinary output hides provider warning",
args: []string{"config", "show", "--config", "fixtures/before.yml"},
wantOutput: "greeting: Hello\nschema:\n version: 1\n",
},
{
name: "quiet suppresses configuration report",
args: []string{"config", "show", "--config", "fixtures/before.yml", "--quiet"},
quiet: true,
},
{
name: "verbose shows ConfigKit warning",
args: []string{"config", "show", "--config", "fixtures/before.yml", "--verbose"},
wantOutput: "greeting: Hello\nschema:\n version: 1\n",
wantDiagnostic: "schema version 1 is supported",
},
} {
t.Run(test.name, func(t *testing.T) {
outcome, output, diagnostics := executeSimple(t, test.args...)
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil {
t.Fatalf("outcome = %#v", outcome)
}
if output != test.wantOutput {
t.Errorf("output = %q, want %q", output, test.wantOutput)
}
if test.wantDiagnostic != "" && !strings.Contains(diagnostics, test.wantDiagnostic) {
t.Errorf("diagnostics = %q, want provider warning %q", diagnostics, test.wantDiagnostic)
}
if test.wantDiagnostic == "" && diagnostics != "" {
t.Errorf("diagnostics = %q, want no warning outside verbose mode", diagnostics)
}
if strings.Contains(diagnostics, "ERROR:") {
t.Errorf("diagnostics %q unexpectedly contain a terminal failure", diagnostics)
}
if test.quiet && (outcome.Presentation.Output.State != commandkit.DeliverySuppressed || outcome.Presentation.Diagnostics.State != commandkit.DeliverySuppressed) {
t.Errorf("quiet presentation = %#v, want report and provider warning suppressed", outcome.Presentation)
}
})
}
}
func TestSimpleCustomCommandUsesSharedReportingFlags(t *testing.T) {
outcome, output, diagnostics := executeSimple(t, "greet", "--quiet")
if outcome.Kind != commandkit.OutcomeSuccess || output != "" || diagnostics != "" {
t.Fatalf("quiet greet outcome/output/diagnostics = %#v / %q / %q", outcome, output, diagnostics)
}
}
func executeSimple(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()
}
func field(mapping *yaml.Node, name string) *yaml.Node {
if mapping == nil || mapping.Kind != yaml.MappingNode {
return nil

View File

@@ -2,7 +2,6 @@ package ckconfig
import (
"bytes"
"errors"
"fmt"
"io"
@@ -54,70 +53,54 @@ type secretReferenceDTO struct {
func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
selected := plan.scopes[0]
name := selected.scope.scope.Name
reporter := invocation.Reporter()
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, nil, false)
return configFailure("Configuration explanation was canceled.", commandkit.Explanation{}, err)
}
if selected.scope.scope.Acquire == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false)
cause := fmt.Errorf("configuration scope %q has no acquisition callback", name)
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), commandkit.Explanation{}, cause)
}
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Explain,
Selection: plan.selection,
}, selected.input, state)
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if acquireErr != nil {
return configOperationFailure(acquireErr, diagnostics, false)
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), acquired.Explanation, acquireErr)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
}
if acquired.Value.Explanation == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide an explanation reader", name), diagnostics, false)
cause := fmt.Errorf("configuration scope %q did not provide an explanation reader", name)
return configFailure("Configuration scope did not provide explanation data.", acquired.Explanation, cause)
}
explanation, found := acquired.Value.Explanation.Explain(plan.path)
if !found {
return configOperationFailure(fmt.Errorf("configuration path %q is not known in scope %q", plan.path.String(), name), diagnostics, false)
cause := fmt.Errorf("configuration path %q is not known in scope %q", plan.path.String(), name)
return configFailure("The requested configuration path is not available.", acquired.Explanation, cause)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
}
if plan.format != "yaml" {
return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, plan.path, diagnostics)
}
var report bytes.Buffer
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
return configOperationFailure(err, diagnostics, false)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
}
if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil {
return configDeliveryFailure(err, diagnostics, true)
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
}
func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path configkit.Path, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
checked := &checkedWriter{writer: writer}
renderErr := reader.WriteExplanation(checked, path)
if checked.failure != nil {
if renderErr == nil {
renderErr = checked.failure
} else {
renderErr = errors.Join(renderErr, checked.failure)
err := reporter.RenderOutput(func(writer io.Writer) error {
return acquired.Value.Explanation.WriteExplanation(writer, plan.path)
})
if err != nil && invocation.Context.Err() != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
}
return commandkit.Success()
}
if renderErr != nil {
outcome := configOperationFailure(renderErr, diagnostics, true)
outcome.DeliveryError = checked.failure
return outcome
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
var report bytes.Buffer
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
return nil, err
}
return report.Bytes(), nil
})
}
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {

View File

@@ -1,6 +1,7 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"fmt"
@@ -10,7 +11,6 @@ import (
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
type scopeMetadata struct {
@@ -265,19 +265,18 @@ func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit.
)},
CheckInput: func(input scopeInventoryInput) error { return checkConfigFormat(input.Format) },
Run: func(invocation commandkit.Invocation, input scopeInventoryInput) commandkit.Outcome {
var output []byte
var err error
if input.Format == "yaml" {
output, err = yaml.Marshal(prepared.inventory)
} else {
output = []byte(formatScopeInventory(prepared.inventory))
}
if err != nil {
return commandkit.Failure(err)
}
if err := writeConfigReport(invocation.Streams.Out, output); err != nil {
return incompleteConfigReport(err)
}
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
var report bytes.Buffer
if input.Format == "yaml" {
if err := encodeYAML(&report, prepared.inventory); err != nil {
return err
}
} else {
_, _ = report.WriteString(formatScopeInventory(prepared.inventory))
}
_, err := writer.Write(report.Bytes())
return err
})
return commandkit.Success()
},
}
@@ -314,21 +313,3 @@ func checkConfigFormat(format string) error {
}
func parseConfigString(value string) (string, error) { return value, nil }
func writeConfigReport(writer io.Writer, content []byte) error {
written, err := writer.Write(content)
if err != nil {
return err
}
if written != len(content) {
return io.ErrShortWrite
}
return nil
}
func incompleteConfigReport(err error) commandkit.Outcome {
outcome := commandkit.Failure(err)
outcome.ReportIncomplete = true
outcome.DeliveryError = err
return outcome
}

View File

@@ -161,7 +161,7 @@ func TestValidationPreparesIndependentScopeInputsAndOneSharedState(t *testing.T)
}
app := newConfigTestApp(t, Command(suite))
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--config", "chosen.yaml", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: &diagnostic})
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--config", "chosen.yaml", "--campaign", "spring", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(diagnostic.String(), "app warning") || !strings.Contains(diagnostic.String(), "promptkit warning") {
t.Fatalf("validation outcome=%#v diagnostics=%q", outcome, diagnostic.String())
}
@@ -225,6 +225,51 @@ func TestRequiredSelectorAppliesOnlyToItsSelectedScope(t *testing.T) {
}
}
func TestConfigAliasesForwardSelectedTenantAndGlobalHelp(t *testing.T) {
type input struct{ Tenant commandkit.Supplied[string] }
flag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant"}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(target *input, value commandkit.Supplied[string]) { target.Tenant = value })
var acquired []commandkit.Supplied[string]
command := Command(Suite[input, struct{}]{
Selectors: []Selector[input]{{Binding: flag, Scopes: []string{"promptkit"}}},
Scopes: []Scope[input, struct{}]{
{
Name: "promptkit",
Operations: []Operation{Validate},
Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) {
acquired = append(acquired, value.Tenant)
return commandkit.Acquired[View]{}, nil
},
},
},
})
app, err := commandkit.New(commandkit.Identity{Name: "example"},
command,
commandkit.Alias("check-config", "config validate"),
commandkit.FlagAliasFor("config validate", "--tenant-id", "--tenant"),
commandkit.FlagAlias("--hlep", "--help"),
)
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"check-config", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "validate") || len(acquired) != 0 {
t.Fatalf("global help alias outcome/output/acquisitions = (%#v, %q, %#v)", outcome, output.String(), acquired)
}
outcome = app.Execute(context.Background(), []string{"check-config", "--scope", "promptkit", "--tenant-id=acme"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || len(acquired) != 1 || acquired[0] != (commandkit.Supplied[string]{Value: "acme", Set: true}) {
t.Fatalf("aliased selector outcome/acquisitions = (%#v, %#v)", outcome, acquired)
}
outcome = app.Execute(context.Background(), []string{"config", "validate", "--scope", "promptkit", "--tenant=canonical"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || len(acquired) != 2 || acquired[1] != (commandkit.Supplied[string]{Value: "canonical", Set: true}) {
t.Fatalf("canonical selector outcome/acquisitions = (%#v, %#v)", outcome, acquired)
}
}
func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
var output bytes.Buffer
var acquisitionOutputLengths []int
@@ -244,7 +289,7 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
makeScope("promptkit", "---\nname: promptkit\n", providerErr),
}}))
outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr || output.Len() != 0 {
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr || output.Len() != 0 || outcome.Presentation.Output.State != commandkit.DeliveryNone {
t.Fatalf("aggregate provider failure outcome=%#v output=%q", outcome, output.String())
}
if len(acquisitionOutputLengths) != 2 || acquisitionOutputLengths[0] != 0 || acquisitionOutputLengths[1] != 0 {
@@ -258,7 +303,7 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
makeScope("promptkit", "name: promptkit\n", nil),
}}))
outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliveryComplete {
t.Fatalf("aggregate show outcome = %#v", outcome)
}
want := "---\n# scope: app\nname: app\n---\n# scope: promptkit\nname: promptkit\n"
@@ -267,6 +312,109 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
}
}
func TestConfigShowSeparatesWarningsFromTerminalContextAcrossModes(t *testing.T) {
providerErr := errors.New("private provider cause")
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{
Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent warning")},
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
_, err := io.WriteString(writer, "native terminal context\n")
return err
}}},
}, providerErr
},
}}}))
for _, test := range []struct {
name string
flags []string
wantWarning bool
wantTerminal bool
}{
{name: "ordinary", wantTerminal: true},
{name: "verbose", flags: []string{"--verbose"}, wantWarning: true, wantTerminal: true},
{name: "quiet", flags: []string{"--quiet"}, wantTerminal: true},
} {
t.Run(test.name, func(t *testing.T) {
args := append([]string{"config", "show"}, test.flags...)
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr {
t.Fatalf("provider failure outcome = %#v", outcome)
}
if got := strings.Contains(diagnostic.String(), "independent warning"); got != test.wantWarning {
t.Fatalf("warning visibility = %v, output %q", got, diagnostic.String())
}
if got := strings.Contains(diagnostic.String(), "native terminal context"); got != test.wantTerminal {
t.Fatalf("terminal context visibility = %v, output %q", got, diagnostic.String())
}
if strings.Contains(diagnostic.String(), providerErr.Error()) {
t.Fatalf("cause text was duplicated into presentation: %q", diagnostic.String())
}
if test.wantWarning && strings.Index(diagnostic.String(), "independent warning") > strings.Index(diagnostic.String(), "native terminal context") {
t.Fatalf("deferred warning did not precede terminal context: %q", diagnostic.String())
}
})
}
}
func TestConfigShowAndValidationUseManagedRendering(t *testing.T) {
t.Run("quiet show still records native render errors", func(t *testing.T) {
renderErr := errors.New("native renderer failed")
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Effective: failedEffective{err: renderErr}}}, nil
},
}}}))
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "show", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || outcome.Cause != nil || output.Len() != 0 {
t.Fatalf("quiet render failure outcome=%#v output=%q", outcome, output.String())
}
})
for _, operation := range []Operation{Show, Validate} {
t.Run(string(operation)+" write failure", func(t *testing.T) {
writeErr := errors.New("report destination failed")
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app",
Operations: []Operation{operation},
Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("value: ready\n")}}, nil
},
}}}))
commandName := "show"
if operation == Validate {
commandName = "validate"
}
outcome := app.Execute(context.Background(), []string{"config", commandName}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Cause != nil {
t.Fatalf("report write failure outcome = %#v", outcome)
}
})
t.Run(string(operation)+" quiet suppression", func(t *testing.T) {
writeErr := errors.New("quiet destination should not be used")
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app",
Operations: []Operation{operation},
Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("value: ready\n")}}, nil
},
}}}))
commandName := "show"
if operation == Validate {
commandName = "validate"
}
outcome := app.Execute(context.Background(), []string{"config", commandName, "--quiet"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliverySuppressed || outcome.Presentation.Output.WriteError != nil || outcome.Cause != nil {
t.Fatalf("quiet report outcome = %#v", outcome)
}
})
}
}
func TestSourcesReadsNativeInventoryOnceAndPreservesZeroValues(t *testing.T) {
reader := &sourceInventory{values: []configkit.SourceInfo{{
ID: "synthetic", Label: "", Path: "", Layer: configkit.Layer("base"), Kind: configkit.SourceKind("mapping"), UnderlayIndex: 0,
@@ -324,13 +472,28 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
}
}
func TestReportWriteFailureTracksIncompleteDeliveryAndCancellation(t *testing.T) {
func TestExplainTracksNativeRenderFailureWithoutReplacingOperationCause(t *testing.T) {
renderErr := errors.New("native explanation renderer failed")
reader := &failingNativeExplanation{err: renderErr}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Explanation: reader}}, nil
},
}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", "/token"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || output.String() != "partial native explanation\n" {
t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String())
}
}
func TestReportWriteFailureIsRecordedByReporterAndPreservesCancellation(t *testing.T) {
writeErr := errors.New("output failed")
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Sources: &sourceInventory{values: []configkit.SourceInfo{}}}}, nil
}}}}))
outcome := app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
if !errors.Is(outcome.Cause, writeErr) || outcome.DeliveryError != writeErr || !outcome.ReportIncomplete {
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("source delivery failure outcome = %#v", outcome)
}
@@ -349,7 +512,7 @@ func TestReportWriteFailureTracksIncompleteDeliveryAndCancellation(t *testing.T)
}}))
var output bytes.Buffer
outcome = app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if !errors.Is(outcome.Cause, context.Canceled) || !outcome.ReportIncomplete || calls != 2 || output.String() != "app: valid\n" {
if !errors.Is(outcome.Cause, context.Canceled) || outcome.Presentation.Output.State != commandkit.DeliveryComplete || calls != 2 || output.String() != "app: valid\n" {
t.Fatalf("cancelled aggregate validation outcome=%#v calls=%d output=%q", outcome, calls, output.String())
}
}
@@ -361,6 +524,12 @@ func (value effectiveValue) WriteEffective(writer io.Writer) error {
return err
}
type failedEffective struct{ err error }
func (value failedEffective) WriteEffective(io.Writer) error {
return value.err
}
type sourceInventory struct {
values []configkit.SourceInfo
calls int
@@ -377,6 +546,19 @@ type nativeExplanation struct {
found bool
}
type failingNativeExplanation struct{ err error }
func (reader *failingNativeExplanation) Explain(configkit.Path) (configkit.Explanation, bool) {
return configkit.Explanation{}, true
}
func (reader *failingNativeExplanation) WriteExplanation(writer io.Writer, _ configkit.Path) error {
if _, err := io.WriteString(writer, "partial native explanation\n"); err != nil {
return err
}
return reader.err
}
func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
reader.path = path
return reader.value, reader.found

View File

@@ -1,18 +1,18 @@
package ckconfig
import (
"errors"
"fmt"
"io"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Operation, input *configCommandInput[I, S]) commandkit.Outcome {
if input == nil || input.plan == nil {
return commandkit.Failure(errors.New("configuration operation was not prepared"))
return commandkit.Failure("Configuration operation was not prepared.")
}
if err := invocation.Context.Err(); err != nil {
return commandkit.Failure(err)
return configFailure("Configuration operation was canceled.", commandkit.Explanation{}, err)
}
plan := input.plan
var state S
@@ -31,19 +31,70 @@ func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Op
case Diff:
return runConfigDiff(invocation, plan, state)
default:
return commandkit.Failure(fmt.Errorf("unsupported configuration operation %q", operation))
return commandkit.Failure(fmt.Sprintf("Unsupported configuration operation %q.", operation))
}
}
func configOperationFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome {
outcome := commandkit.Failure(cause)
outcome.Diagnostics = diagnostics
outcome.ReportIncomplete = reportStarted
func queueConfigDiagnostics(reporter *commandkit.Reporter, diagnostics []commandkit.Diagnostic) {
deferred := reporter.AtEnd()
for _, diagnostic := range diagnostics {
if diagnostic.Write == nil {
continue
}
switch diagnostic.Severity {
case commandkit.SeverityInfo:
_ = deferred.Info("", diagnostic.Write)
case commandkit.SeverityDebug:
_ = deferred.Debug("", diagnostic.Write)
case commandkit.SeverityProgress:
_ = reporter.Progress("", diagnostic.Write)
default:
_ = deferred.Warn("", diagnostic.Write)
}
}
}
func configFailure(message string, explanation commandkit.Explanation, cause error) commandkit.Outcome {
if explanation.Message != "" || len(explanation.Details) > 0 {
message = explanation.Message
}
outcome := commandkit.Failure(message, explanation.Details...)
if cause != nil {
outcome = outcome.WithCause(cause)
}
return outcome
}
func configDeliveryFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome {
outcome := configOperationFailure(cause, diagnostics, reportStarted)
outcome.DeliveryError = cause
return outcome
func configAcquisitionFailure(
reporter *commandkit.Reporter,
operation, scope string,
acquired commandkit.Acquired[View],
cause error,
) commandkit.Outcome {
queueConfigDiagnostics(reporter, acquired.Diagnostics)
message := fmt.Sprintf("Unable to %s configuration scope %q.", operation, scope)
return configFailure(message, acquired.Explanation, cause)
}
// Preserve the preparation/delivery cancellation checkpoint for buffered reports.
func renderPreparedConfigReport(invocation commandkit.Invocation, explanation commandkit.Explanation, prepare func() ([]byte, error)) commandkit.Outcome {
var canceled error
renderErr := invocation.Reporter().RenderOutput(func(writer io.Writer) error {
report, err := prepare()
if err != nil {
return err
}
if canceled = invocation.Context.Err(); canceled != nil {
return nil
}
_, err = writer.Write(report)
return err
})
if canceled == nil && renderErr != nil {
canceled = invocation.Context.Err()
}
if canceled != nil {
return configFailure("Configuration report preparation was canceled.", explanation, canceled)
}
return commandkit.Success()
}

View File

@@ -0,0 +1,69 @@
package ckconfig
import (
"context"
"errors"
"io"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
func TestBufferedReportCancellationBeforeDelivery(t *testing.T) {
for _, quiet := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "quiet"}[quiet], func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
prepared := false
app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "report",
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
return renderPreparedConfigReport(invocation, commandkit.Explanation{Message: "selected context"}, func() ([]byte, error) {
prepared = true
cancel()
return []byte("prepared report"), nil
})
},
}))
args := []string{"report"}
if quiet {
args = append(args, "--quiet")
}
writes := 0
got := app.Execute(ctx, args, commandkit.Streams{Out: reportWriteFunc(func(p []byte) (int, error) {
writes++
return len(p), nil
})})
if !prepared || writes != 0 || got.Status != 1 || !errors.Is(got.Cause, context.Canceled) || got.Explanation.Message != "selected context" {
t.Fatalf("prepared=%t writes=%d outcome=%#v", prepared, writes, got)
}
if got.Presentation.Output.RenderError != nil || got.Presentation.Output.WriteError != nil {
t.Fatalf("cancellation became a presentation failure: %#v", got.Presentation.Output)
}
})
}
}
type reportWriteFunc func([]byte) (int, error)
func (write reportWriteFunc) Write(p []byte) (int, error) { return write(p) }
func TestBufferedReportDoesNotRejectCancellationAfterDeliveryStarts(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "report",
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
return renderPreparedConfigReport(invocation, commandkit.Explanation{}, func() ([]byte, error) {
return []byte("report"), nil
})
},
}))
got := app.Execute(ctx, []string{"report"}, commandkit.Streams{Out: reportWriteFunc(func(p []byte) (int, error) {
cancel()
return io.Discard.Write(p)
})})
if got.Status != 0 || got.Cause != nil {
t.Fatalf("post-delivery cancellation changed the operation outcome: %#v", got)
}
}

View File

@@ -2,7 +2,6 @@ package ckconfig
import (
"bytes"
"errors"
"fmt"
"io"
"strconv"
@@ -18,101 +17,76 @@ type effectiveDocument struct {
func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
documents := make([]effectiveDocument, 0, len(plan.scopes))
diagnostics := make([]commandkit.Diagnostic, 0)
reporter := invocation.Reporter()
for _, selected := range plan.scopes {
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
}
if selected.scope.scope.Acquire == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", selected.scope.scope.Name), diagnostics, false)
message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name)
return commandkit.Failure(message)
}
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Show,
Selection: plan.selection,
}, selected.input, state)
diagnostics = append(diagnostics, acquired.Diagnostics...)
if acquireErr != nil {
return configOperationFailure(acquireErr, diagnostics, false)
return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr)
}
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration show was canceled.", acquired.Explanation, err)
}
if acquired.Value.Effective == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide an effective configuration writer", selected.scope.scope.Name), diagnostics, false)
message := fmt.Sprintf("Configuration scope %q did not provide an effective configuration writer.", selected.scope.scope.Name)
return commandkit.Failure(message)
}
documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value})
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
}
if len(documents) == 1 && plan.scopeName != "all" {
checked := &checkedWriter{writer: invocation.Streams.Out}
renderErr := documents[0].view.Effective.WriteEffective(checked)
if checked.failure != nil {
if renderErr == nil {
renderErr = checked.failure
} else {
renderErr = errors.Join(renderErr, checked.failure)
err := reporter.RenderOutput(func(writer io.Writer) error {
return documents[0].view.Effective.WriteEffective(writer)
})
if err != nil && invocation.Context.Err() != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
}
return commandkit.Success()
}
err := reporter.RenderOutput(func(writer io.Writer) error {
for index, document := range documents {
if err := invocation.Context.Err(); err != nil {
return err
}
var native bytes.Buffer
if err := document.view.Effective.WriteEffective(&native); err != nil {
return err
}
if err := invocation.Context.Err(); err != nil {
return err
}
framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes())
if index > 0 && hasDirective {
if _, err := io.WriteString(writer, "...\n"); err != nil {
return err
}
}
if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' {
framed = append(framed, '\n')
}
if _, err := writer.Write(framed); err != nil {
return err
}
}
if renderErr != nil {
outcome := configOperationFailure(renderErr, diagnostics, true)
outcome.DeliveryError = checked.failure
return outcome
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
return nil
})
if err != nil && invocation.Context.Err() != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
}
written := false
for index, document := range documents {
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, written)
}
var native bytes.Buffer
if err := document.view.Effective.WriteEffective(&native); err != nil {
return configOperationFailure(err, diagnostics, written)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, written)
}
framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes())
if index > 0 && hasDirective {
if err := writeConfigReport(invocation.Streams.Out, []byte("...\n")); err != nil {
return configDeliveryFailure(err, diagnostics, true)
}
}
if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' {
framed = append(framed, '\n')
}
if err := writeConfigReport(invocation.Streams.Out, framed); err != nil {
return configDeliveryFailure(err, diagnostics, true)
}
written = true
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
}
type checkedWriter struct {
writer io.Writer
failure error
}
func (writer *checkedWriter) Write(content []byte) (int, error) {
if writer.failure != nil {
return 0, writer.failure
}
written, err := writer.writer.Write(content)
if err == nil && written != len(content) {
err = io.ErrShortWrite
}
if err != nil {
writer.failure = err
}
return written, err
return commandkit.Success()
}
type yamlPreamble struct {

View File

@@ -52,25 +52,28 @@ type versionDTO struct {
func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
selected := plan.scopes[0]
name := selected.scope.scope.Name
reporter := invocation.Reporter()
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, nil, false)
return configFailure("Configuration source listing was canceled.", commandkit.Explanation{}, err)
}
if selected.scope.scope.Acquire == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false)
cause := fmt.Errorf("configuration scope %q has no acquisition callback", name)
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), commandkit.Explanation{}, cause)
}
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Sources,
Selection: plan.selection,
}, selected.input, state)
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if acquireErr != nil {
return configOperationFailure(acquireErr, diagnostics, false)
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), acquired.Explanation, acquireErr)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
}
if acquired.Value.Sources == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide a source reader", name), diagnostics, false)
cause := fmt.Errorf("configuration scope %q did not provide a source reader", name)
return configFailure("Configuration scope did not provide source information.", acquired.Explanation, cause)
}
sources := acquired.Value.Sources.Sources()
@@ -111,23 +114,20 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
}
document.Sources = append(document.Sources, item)
}
var report bytes.Buffer
if plan.format == "yaml" {
if err := encodeYAML(&report, document); err != nil {
return configOperationFailure(err, diagnostics, false)
}
} else {
writeSourceReportText(&report, document)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
}
if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil {
return configDeliveryFailure(err, diagnostics, true)
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
var report bytes.Buffer
if plan.format == "yaml" {
if err := encodeYAML(&report, document); err != nil {
return nil, err
}
} else {
writeSourceReportText(&report, document)
}
return report.Bytes(), nil
})
}
func writeSourceReportText(writer *bytes.Buffer, document sourceReport) {

View File

@@ -3,6 +3,7 @@ package ckconfig
import (
"bytes"
"fmt"
"io"
"strings"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
@@ -10,44 +11,50 @@ import (
)
func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
diagnostics := make([]commandkit.Diagnostic, 0)
reporter := invocation.Reporter()
writtenRecord := false
for _, selected := range plan.scopes {
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, writtenRecord)
return configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err)
}
if selected.scope.scope.Acquire == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", selected.scope.scope.Name), diagnostics, writtenRecord)
message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name)
return commandkit.Failure(message)
}
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Validate,
Selection: plan.selection,
}, selected.input, state)
diagnostics = append(diagnostics, acquired.Diagnostics...)
if acquireErr != nil {
return configOperationFailure(acquireErr, diagnostics, writtenRecord)
return configAcquisitionFailure(reporter, "validate", selected.scope.scope.Name, acquired, acquireErr)
}
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, writtenRecord)
return configFailure("Configuration validation was canceled.", acquired.Explanation, err)
}
record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format)
if err != nil {
return configOperationFailure(err, diagnostics, writtenRecord)
}
if plan.format == "yaml" && !writtenRecord {
if err := writeConfigReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil {
return configDeliveryFailure(err, diagnostics, true)
err := reporter.RenderOutput(func(writer io.Writer) error {
record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format)
if err != nil {
return err
}
}
if err := writeConfigReport(invocation.Streams.Out, record); err != nil {
return configDeliveryFailure(err, diagnostics, true)
if plan.format == "yaml" && !writtenRecord {
if _, err := io.WriteString(writer, "scopes:\n"); err != nil {
return err
}
}
_, err = writer.Write(record)
return err
})
if err != nil {
if ctxErr := invocation.Context.Err(); ctxErr != nil {
return configFailure("Configuration validation was canceled.", acquired.Explanation, ctxErr)
}
return commandkit.Success()
}
writtenRecord = true
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
return commandkit.Success()
}
func validationSuccessRecord(scope, format string) ([]byte, error) {

View File

@@ -88,26 +88,33 @@ type Suite[I, S any] struct {
// CheckInput validates selected inspection, requirements, validation, smoke,
// or catalog input before state creation.
CheckInput func(ckconfig.Selection, I) error
// InspectPrompt inspects the selected prompt after input and state preparation.
// InspectPrompt inspects the selected prompt after input and state
// preparation. Its acquisition carries independent loader diagnostics and
// terminal context alongside any native inspection result.
InspectPrompt func(context.Context, PromptRequest, I, S) (commandkit.Acquired[*promptkit.PromptInspection], error)
// InspectProfile inspects the selected profile after input and state preparation.
// InspectProfile inspects the selected profile after input and state
// preparation. Its acquisition carries independent loader diagnostics and
// terminal context alongside any native inspection result.
InspectProfile func(context.Context, ProfileRequest, I, S) (commandkit.Acquired[*promptkit.ProfileInspection], error)
// InspectRequirements inspects native PromptKit requirements after input and
// state preparation. It is optional; a nil callback omits
// `promptkit requirements show` without disabling validation or other
// capabilities.
// state preparation. Its acquisition carries independent loader diagnostics
// and terminal context alongside any native inspection result. It is
// optional; a nil callback omits `promptkit requirements show` without
// disabling validation or other capabilities.
InspectRequirements func(context.Context, RequirementsRequest, I, S) (commandkit.Acquired[*promptkit.RequirementInspection], error)
// ValidateRequest validates a concrete PromptKit request after input and
// state preparation. It is optional; a nil callback omits
// `promptkit requirements validate` without disabling discovery or other
// capabilities. The acquisition carries diagnostics and an empty value on
// success; PromptKit errors are returned unchanged where supported.
// capabilities. The acquisition carries independent diagnostics, terminal
// context, and an empty value on success; PromptKit errors are returned
// unchanged where supported.
ValidateRequest func(context.Context, ValidationRequest, I, S) (commandkit.Acquired[struct{}], error)
// SmokeTest runs one concrete native smoke-test operation after input and
// state preparation. It is optional; nil omits only the smoke-test command.
// The acquisition retains a native report even when the callback also
// returns an error. For `test all`, CommandKit calls the captured callback
// sequentially for each selected kind after preparing input and state once.
// The acquisition retains a native report, independent diagnostics, and
// terminal context even when the callback also returns an error. For
// `test all`, CommandKit calls the captured callback sequentially for each
// selected kind after preparing input and state once.
SmokeTest func(context.Context, SmokeRequest, I, S) (commandkit.Acquired[*promptkit.SmokeTestReport], error)
// ListPrompts lists prompt identities after input and state preparation.
// It is optional independently of the other catalogs; nil omits only this
@@ -142,10 +149,12 @@ type Suite[I, S any] struct {
// PromptKit unchanged; the loader receives the seed's original configuration
// selection. A nil builder forwards the seed request unchanged. A builder
// failure prevents engine loading and preserves its supported error identity.
// Native validation errors are returned with supported identity, and
// configuration diagnostics from engine loading remain in the acquisition. A
// nil loader is retained as a suite wiring issue and does not install a
// validation callback. Callback references and objects remain caller-owned.
// Native validation errors supply terminal explanations and are returned with
// supported identity, and
// configuration diagnostics and terminal context from engine loading remain
// separate in the acquisition. A nil loader is retained as a suite wiring
// issue and does not install a validation callback. Callback references and
// objects remain caller-owned.
func WithRequestValidation[I, S any](
suite Suite[I, S],
load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error),
@@ -175,34 +184,56 @@ func WithRequestValidation[I, S any](
return commandkit.Acquired[struct{}]{}, fmt.Errorf("PromptKit request validation after building: %w", err)
}
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
if err != nil {
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, err
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
if err := promptKitContextError(ctx); err != nil {
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit request validation before native validation: %w", err)
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit request validation before native validation: %w", err)
}
if err := engine.ValidateRequest(ctx, concreteRequest); err != nil {
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, err
messages = messages.withOperationError(err)
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
if err := promptKitContextError(ctx); err != nil {
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit request validation after native validation: %w", err)
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit request validation after native validation: %w", err)
}
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, nil
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, nil
}
return suite
}
type engineLoader[I, S any] func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)
type promptKitAcquisition struct {
diagnostics []commandkit.Diagnostic
explanation commandkit.Explanation
}
// withOperationError deliberately presents a native PromptKit operation error.
// Loader and request-builder errors remain application-owned. Native structured
// context takes precedence when an acquisition already supplies it.
func (messages promptKitAcquisition) withOperationError(err error) promptKitAcquisition {
if err == nil || messages.explanation.Message != "" {
return messages
}
for _, detail := range messages.explanation.Details {
if detail != nil {
return messages
}
}
messages.explanation.Message = err.Error()
return messages
}
// ConfigScope adapts PromptKit's captured configuration inspection to the
// standard configuration commands, including native comparison when the scope
// declares Diff. The loader is not called during declaration construction or
// installation; it is called only when a config operation selects the
// promptkit scope, with that operation's input and shared state. PromptKit
// diagnostics and supported error identity are returned through ckconfig's
// acquisition and comparison contracts.
// independent diagnostics, terminal context, and supported error identity are
// returned through ckconfig's acquisition and comparison contracts.
func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) ckconfig.Scope[I, S] {
scope := ckconfig.Scope[I, S]{
Name: "promptkit",
@@ -214,9 +245,9 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
}
scope.Acquire = func(ctx context.Context, request ckconfig.Request, input I, state S) (commandkit.Acquired[ckconfig.View], error) {
_, inspection, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
_, inspection, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
if err != nil {
return commandkit.Acquired[ckconfig.View]{Diagnostics: diagnostics}, err
return commandkit.Acquired[ckconfig.View]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
return commandkit.Acquired[ckconfig.View]{
Value: ckconfig.View{
@@ -224,7 +255,8 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
Sources: inspection,
Explanation: inspection,
},
Diagnostics: diagnostics,
Diagnostics: messages.diagnostics,
Explanation: messages.explanation,
}, nil
}
scope.Compare = func(ctx context.Context, request ckconfig.ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
@@ -232,31 +264,31 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("PromptKit scope %q comparison before left load: %w", scope.Name, err)
}
_, leftInspection, leftDiagnostics, err := loadPromptKitEngine(ctx, request.Left.Selection, request.Left.Input, state, "left", load)
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
_, leftInspection, messages, err := loadPromptKitEngine(ctx, request.Left.Selection, request.Left.Input, state, "left", load)
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q left comparison load: %w", scope.Name, err)
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q left comparison load: %w", scope.Name, err)
}
if err := promptKitContextError(ctx); err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q comparison after left load: %w", scope.Name, err)
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q comparison after left load: %w", scope.Name, err)
}
_, rightInspection, rightDiagnostics, err := loadPromptKitEngine(ctx, request.Right.Selection, request.Right.Input, state, "right", load)
diagnostics = append(diagnostics, rightDiagnostics...)
_, rightInspection, rightMessages, err := loadPromptKitEngine(ctx, request.Right.Selection, request.Right.Input, state, "right", load)
messages = appendPromptKitAcquisition(messages, rightMessages)
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q right comparison load: %w", scope.Name, err)
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q right comparison load: %w", scope.Name, err)
}
if err := promptKitContextError(ctx); err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q comparison before native diff: %w", scope.Name, err)
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q comparison before native diff: %w", scope.Name, err)
}
differences, err := leftInspection.Diff(rightInspection)
messages = messages.withOperationError(err)
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err)
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err)
}
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, nil
}
return scope
}
@@ -280,8 +312,9 @@ func promptKitContextError(ctx context.Context) error {
// shared invocation state to the loader before calling PromptKit's public
// listing or inspection method. Applications can narrow, wrap, or replace these
// callbacks before Command captures them. A nil loader is retained as an
// adapter issue. Each callback returns loader diagnostics with the native
// operation's value and error.
// adapter issue. Each callback returns independent loader diagnostics and
// terminal context alongside the native operation's value and error. Native
// operation errors supply terminal explanations when structured context is absent.
func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) Suite[I, S] {
if load == nil {
return Suite[I, S]{issues: []error{errors.New("PromptKit engine loader is nil")}}
@@ -289,68 +322,76 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) (
return Suite[I, S]{
ListPrompts: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
if err != nil {
return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: diagnostics}, err
return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
prompts, err := engine.ListPrompts(ctx, request.Filter)
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: prompts, Diagnostics: diagnostics}, err
messages = messages.withOperationError(err)
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: prompts, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
},
ListProfiles: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]string], error) {
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
if err != nil {
return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err
return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
profiles, err := engine.ListProfiles(ctx, request.Filter)
return commandkit.Acquired[[]string]{Value: profiles, Diagnostics: diagnostics}, err
messages = messages.withOperationError(err)
return commandkit.Acquired[[]string]{Value: profiles, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
},
ListBackends: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]string], error) {
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
if err != nil {
return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err
return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
backends, err := engine.ListBackends(ctx, request.Filter)
return commandkit.Acquired[[]string]{Value: backends, Diagnostics: diagnostics}, err
messages = messages.withOperationError(err)
return commandkit.Acquired[[]string]{Value: backends, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
},
ListNamespaces: func(ctx context.Context, request NamespaceRequest, input I, state S) (commandkit.Acquired[[]string], error) {
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
if err != nil {
return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err
return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
namespaces, err := engine.ListNamespaces(ctx, request.Kinds...)
return commandkit.Acquired[[]string]{Value: namespaces, Diagnostics: diagnostics}, err
messages = messages.withOperationError(err)
return commandkit.Acquired[[]string]{Value: namespaces, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
},
ListCollections: func(ctx context.Context, selection ckconfig.Selection, input I, state S) (commandkit.Acquired[[]promptkit.CollectionInfo], error) {
engine, _, diagnostics, err := loadPromptKitEngine(ctx, selection, input, state, "", load)
engine, _, messages, err := loadPromptKitEngine(ctx, selection, input, state, "", load)
if err != nil {
return commandkit.Acquired[[]promptkit.CollectionInfo]{Diagnostics: diagnostics}, err
return commandkit.Acquired[[]promptkit.CollectionInfo]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
collections, err := engine.ListCollections(ctx)
return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: collections, Diagnostics: diagnostics}, err
messages = messages.withOperationError(err)
return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: collections, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
},
InspectPrompt: func(ctx context.Context, request PromptRequest, input I, state S) (commandkit.Acquired[*promptkit.PromptInspection], error) {
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
if err != nil {
return commandkit.Acquired[*promptkit.PromptInspection]{Diagnostics: diagnostics}, err
return commandkit.Acquired[*promptkit.PromptInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
inspection, err := engine.InspectPrompt(ctx, request.ID)
return commandkit.Acquired[*promptkit.PromptInspection]{Value: inspection, Diagnostics: diagnostics}, err
messages = messages.withOperationError(err)
return commandkit.Acquired[*promptkit.PromptInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
},
InspectProfile: func(ctx context.Context, request ProfileRequest, input I, state S) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
if err != nil {
return commandkit.Acquired[*promptkit.ProfileInspection]{Diagnostics: diagnostics}, err
return commandkit.Acquired[*promptkit.ProfileInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
inspection, err := engine.InspectProfile(ctx, request.ID)
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: diagnostics}, err
messages = messages.withOperationError(err)
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
},
InspectRequirements: func(ctx context.Context, request RequirementsRequest, input I, state S) (commandkit.Acquired[*promptkit.RequirementInspection], error) {
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
if err != nil {
return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: diagnostics}, err
return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
inspection, err := engine.InspectRequirements(ctx, request.Inspection)
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: inspection, Diagnostics: diagnostics}, err
messages = messages.withOperationError(err)
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
},
}
}
@@ -362,41 +403,45 @@ func loadPromptKitEngine[I, S any](
state S,
side string,
load engineLoader[I, S],
) (*promptkit.Engine, promptkit.ConfigurationInspection, []commandkit.Diagnostic, error) {
) (*promptkit.Engine, promptkit.ConfigurationInspection, promptKitAcquisition, error) {
engine, err := load(ctx, selection, input, state)
if err != nil {
var loadErr *configkit.LoadError
if errors.As(err, &loadErr) {
return nil, promptkit.ConfigurationInspection{}, promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics()), err
messages := promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics())
return nil, promptkit.ConfigurationInspection{}, messages, err
}
return nil, promptkit.ConfigurationInspection{}, nil, err
return nil, promptkit.ConfigurationInspection{}, promptKitAcquisition{}, err
}
if engine == nil {
return nil, promptkit.ConfigurationInspection{}, nil, errors.New("PromptKit configuration loader returned a nil engine")
return nil, promptkit.ConfigurationInspection{}, promptKitAcquisition{}, errors.New("PromptKit configuration loader returned a nil engine")
}
inspection, err := engine.InspectConfiguration()
messages := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics())
messages = messages.withOperationError(err)
if err != nil {
return nil, promptkit.ConfigurationInspection{}, nil, err
return nil, inspection, messages, err
}
diagnostics := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics())
if ctx != nil {
if err := ctx.Err(); err != nil {
return nil, promptkit.ConfigurationInspection{}, diagnostics, err
return nil, promptkit.ConfigurationInspection{}, messages, err
}
}
return engine, inspection, diagnostics, nil
return engine, inspection, messages, nil
}
func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
if len(records) == 0 {
return nil
func appendPromptKitAcquisition(left, right promptKitAcquisition) promptKitAcquisition {
left.diagnostics = append(left.diagnostics, right.diagnostics...)
left.explanation.Details = append(left.explanation.Details, right.explanation.Details...)
if left.explanation.Message == "" {
left.explanation.Message = right.explanation.Message
}
severity := commandkit.SeverityWarning
for _, record := range records {
if record.Severity == configkit.SeverityError {
severity = commandkit.SeverityError
break
}
return left
}
func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) promptKitAcquisition {
if len(records) == 0 {
return promptKitAcquisition{}
}
// Snapshot provider-owned slices before deferring their report to invocation.
sources = append([]configkit.SourceInfo(nil), sources...)
@@ -408,27 +453,48 @@ func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, re
}
records[index].Related = append([]configkit.Location(nil), records[index].Related...)
}
return []commandkit.Diagnostic{{
Severity: severity,
Write: func(writer io.Writer) error {
heading := "Configuration scope " + strconv.Quote(scope)
if side != "" {
heading += " (" + side + ")"
}
if _, err := fmt.Fprintf(writer, "%s:\n", heading); err != nil {
var warnings, failures []configkit.Diagnostic
for _, record := range records {
if record.Severity == configkit.SeverityError {
failures = append(failures, record)
} else {
warnings = append(warnings, record)
}
}
messages := promptKitAcquisition{}
if len(warnings) > 0 {
messages.diagnostics = []commandkit.Diagnostic{{
Severity: commandkit.SeverityWarning,
Write: func(writer io.Writer) error {
return writePromptKitDiagnostics(writer, scope, side, sources, warnings)
},
}}
}
if len(failures) > 0 {
messages.explanation = commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
return writePromptKitDiagnostics(writer, scope, side, sources, failures)
}}}
}
return messages
}
func writePromptKitDiagnostics(writer io.Writer, scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) error {
heading := "Configuration scope " + strconv.Quote(scope)
if side != "" {
heading += " (" + side + ")"
}
if _, err := fmt.Fprintf(writer, "%s:\n", heading); err != nil {
return err
}
if len(sources) > 0 {
if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil {
return err
}
for _, source := range sources {
if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil {
return err
}
if len(sources) > 0 {
if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil {
return err
}
for _, source := range sources {
if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil {
return err
}
}
}
return configkit.WriteDiagnostics(writer, records)
},
}}
}
}
return configkit.WriteDiagnostics(writer, records)
}

View File

@@ -239,7 +239,7 @@ func TestWithSmokeTestingRetainsNilLoaderIssueAndChecksCancellationBeforeBuild(t
}
}
func TestWithSmokeTestingRetainsLoaderDiagnosticsAndErrorIdentity(t *testing.T) {
func TestWithSmokeTestingRetainsLoaderExplanationAndErrorIdentity(t *testing.T) {
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid smoke configuration", []byte("broken: [")))
if loadErr == nil {
t.Fatal("invalid configuration unexpectedly loaded")
@@ -249,7 +249,7 @@ func TestWithSmokeTestingRetainsLoaderDiagnosticsAndErrorIdentity(t *testing.T)
}, nil)
acquired, err := suite.SmokeTest(context.Background(), SmokeRequest{}, 0, 0)
var retained *configkit.LoadError
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || acquired.Value != nil || len(acquired.Diagnostics) == 0 {
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || acquired.Value != nil || len(acquired.Explanation.Details) == 0 {
t.Fatalf("smoke load failure = (%#v, %v)", acquired, err)
}
}
@@ -394,7 +394,7 @@ func TestConfigScopeCompareStopsAfterLeftLoadFailure(t *testing.T) {
}
}
func TestConfigScopeCompareRetainsRightLoadDiagnosticsAndErrorIdentity(t *testing.T) {
func TestConfigScopeCompareRetainsRightLoadExplanationAndErrorIdentity(t *testing.T) {
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid right prompt config", []byte("broken: [")))
if loadErr == nil {
t.Fatal("invalid right configuration unexpectedly loaded")
@@ -415,12 +415,12 @@ func TestConfigScopeCompareRetainsRightLoadDiagnosticsAndErrorIdentity(t *testin
acquired, err := scope.Compare(context.Background(), request, struct{}{})
var retained *configkit.LoadError
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(calls) != 2 || len(acquired.Diagnostics) == 0 {
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(calls) != 2 || len(acquired.Explanation.Details) == 0 {
t.Fatalf("right failure = (%#v, %v), calls=%#v", acquired, err, calls)
}
var output bytes.Buffer
if writeErr := acquired.Diagnostics[0].Write(&output); writeErr != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (right):`) {
t.Fatalf("right diagnostic = %q, %v", output.String(), writeErr)
if writeErr := acquired.Explanation.Details[0](&output); writeErr != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (right):`) {
t.Fatalf("right explanation = %q, %v", output.String(), writeErr)
}
}
@@ -534,7 +534,7 @@ func TestFromEngineInspectsRequirementsWithNativeRequest(t *testing.T) {
}
}
func TestFromEngineRequirementsRetainLoadDiagnosticsAndSelectionErrors(t *testing.T) {
func TestFromEngineRequirementsRetainLoadExplanationAndSelectionErrors(t *testing.T) {
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid requirements configuration", []byte("broken: [")))
if loadErr == nil {
t.Fatal("invalid configuration unexpectedly loaded")
@@ -544,11 +544,8 @@ func TestFromEngineRequirementsRetainLoadDiagnosticsAndSelectionErrors(t *testin
})
acquired, err := loadFailure.InspectRequirements(context.Background(), RequirementsRequest{}, 0, 0)
var retained *configkit.LoadError
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 || acquired.Value != nil {
t.Fatalf("requirements load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err)
}
if acquired.Diagnostics[0].Severity != commandkit.SeverityError {
t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics)
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 || acquired.Value != nil {
t.Fatalf("requirements load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err)
}
nilEngine := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
@@ -651,7 +648,7 @@ func TestFromEngineListsNativeCatalogsWithInvocationValues(t *testing.T) {
}
}
func TestFromEngineCatalogsRetainLoadDiagnosticsAndErrorIdentity(t *testing.T) {
func TestFromEngineCatalogsRetainLoadExplanationAndErrorIdentity(t *testing.T) {
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid catalog configuration", []byte("broken: [")))
if loadErr == nil {
t.Fatal("invalid configuration unexpectedly loaded")
@@ -666,42 +663,42 @@ func TestFromEngineCatalogsRetainLoadDiagnosticsAndErrorIdentity(t *testing.T) {
{name: "prompts", call: func() (int, error) {
acquired, err := suite.ListPrompts(context.Background(), CatalogRequest{}, 0, 0)
var retained *configkit.LoadError
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
t.Errorf("prompt load failure = (%#v, %v)", acquired, err)
}
return len(acquired.Diagnostics), err
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
}},
{name: "profiles", call: func() (int, error) {
acquired, err := suite.ListProfiles(context.Background(), CatalogRequest{}, 0, 0)
var retained *configkit.LoadError
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
t.Errorf("profile load failure = (%#v, %v)", acquired, err)
}
return len(acquired.Diagnostics), err
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
}},
{name: "backends", call: func() (int, error) {
acquired, err := suite.ListBackends(context.Background(), CatalogRequest{}, 0, 0)
var retained *configkit.LoadError
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
t.Errorf("backend load failure = (%#v, %v)", acquired, err)
}
return len(acquired.Diagnostics), err
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
}},
{name: "namespaces", call: func() (int, error) {
acquired, err := suite.ListNamespaces(context.Background(), NamespaceRequest{}, 0, 0)
var retained *configkit.LoadError
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
t.Errorf("namespace load failure = (%#v, %v)", acquired, err)
}
return len(acquired.Diagnostics), err
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
}},
{name: "collections", call: func() (int, error) {
acquired, err := suite.ListCollections(context.Background(), ckconfig.Selection{}, 0, 0)
var retained *configkit.LoadError
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
t.Errorf("collection load failure = (%#v, %v)", acquired, err)
}
return len(acquired.Diagnostics), err
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
}},
} {
t.Run(test.name, func(t *testing.T) {
@@ -782,7 +779,7 @@ func TestAdapterAcquisitionPreservesLoadAndContextErrors(t *testing.T) {
}
}
func TestFromEngineRetainsConfigKitLoadDiagnostics(t *testing.T) {
func TestFromEngineRetainsConfigKitLoadExplanation(t *testing.T) {
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid PromptKit config", []byte("broken: [")))
if loadErr == nil {
t.Fatal("invalid configuration unexpectedly loaded")
@@ -793,11 +790,8 @@ func TestFromEngineRetainsConfigKitLoadDiagnostics(t *testing.T) {
acquired, err := suite.InspectProfile(context.Background(), ProfileRequest{ID: "default"}, 0, 0)
var retained *configkit.LoadError
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err)
}
if acquired.Diagnostics[0].Severity != commandkit.SeverityError {
t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics)
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err)
}
}
@@ -823,25 +817,55 @@ func TestPromptKitDiagnosticsRemainDeferredAndPropagateWriterErrors(t *testing.T
Severity: configkit.SeverityWarning,
Message: "safe warning",
}}
diagnostics := promptKitDiagnostics("promptkit", "", sources, records)
if len(diagnostics) != 1 || diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("diagnostics = %#v", diagnostics)
messages := promptKitDiagnostics("promptkit", "", sources, records)
if len(messages.diagnostics) != 1 || messages.diagnostics[0].Severity != commandkit.SeverityWarning || len(messages.explanation.Details) != 0 {
t.Fatalf("diagnostic messages = %#v", messages)
}
if err := diagnostics[0].Write(failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) {
if err := messages.diagnostics[0].Write(failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) {
t.Fatalf("diagnostic write error = %v", err)
}
var output bytes.Buffer
if err := diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit":`) || !strings.Contains(output.String(), "safe warning") || !strings.Contains(output.String(), `"source"`) {
if err := messages.diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit":`) || !strings.Contains(output.String(), "safe warning") || !strings.Contains(output.String(), `"source"`) {
t.Fatalf("diagnostic text = %q, %v", output.String(), err)
}
sideDiagnostics := promptKitDiagnostics("promptkit", "left", sources, records)
output.Reset()
if err := sideDiagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (left):`) {
if err := sideDiagnostics.diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (left):`) {
t.Fatalf("comparison-side diagnostic text = %q, %v", output.String(), err)
}
}
func TestPromptKitDiagnosticsSeparateIndependentWarningsAndTerminalRecords(t *testing.T) {
sources := []configkit.SourceInfo{{ID: "source", Label: "safe label", Path: "config.yml"}}
primary := configkit.Location{SourceID: "source", Line: 3, Column: 2}
related := configkit.Location{SourceID: "base.yml", Line: 7, Column: 4}
messages := promptKitDiagnostics("promptkit", "right", sources, []configkit.Diagnostic{
{Severity: configkit.SeverityWarning, Message: "independent warning", Primary: &primary, Related: []configkit.Location{related}},
{Severity: configkit.SeverityError, Message: "terminal configuration error", Primary: &related, Related: []configkit.Location{primary}},
})
if len(messages.diagnostics) != 1 || len(messages.explanation.Details) != 1 {
t.Fatalf("mixed acquisition messages = %#v", messages)
}
writeErr := errors.New("terminal explanation writer failed")
if err := messages.explanation.Details[0](failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) {
t.Fatalf("terminal explanation write error = %v", err)
}
var warning, terminal bytes.Buffer
if err := messages.diagnostics[0].Write(&warning); err != nil {
t.Fatal(err)
}
if err := messages.explanation.Details[0](&terminal); err != nil {
t.Fatal(err)
}
if got := warning.String(); !strings.Contains(got, "independent warning") || !strings.Contains(got, `Configuration scope "promptkit" (right):`) || !strings.Contains(got, `"source": label="safe label", path="config.yml"`) || !strings.Contains(got, "source:3:2") || !strings.Contains(got, "related: base.yml:7:4") || strings.Contains(got, "terminal configuration error") {
t.Fatalf("independent warning report = %q", got)
}
if got := terminal.String(); !strings.Contains(got, "terminal configuration error") || !strings.Contains(got, `Configuration scope "promptkit" (right):`) || !strings.Contains(got, `"source": label="safe label", path="config.yml"`) || !strings.Contains(got, "base.yml:7:4") || !strings.Contains(got, "related: source:3:2") || strings.Contains(got, "independent warning") {
t.Fatalf("terminal explanation report = %q", got)
}
}
type failingDiagnosticWriter struct{ err error }
func (writer failingDiagnosticWriter) Write([]byte) (int, error) { return 0, writer.err }

View File

@@ -185,23 +185,23 @@ func newCatalogListCommand[I, S any](suite Suite[I, S], name string) commandkit.
Run: func(invocation commandkit.Invocation, input *catalogInput[I]) commandkit.Outcome {
switch name {
case "prompts":
return runCatalogList(invocation, suite, input, suite.ListPrompts, encodePromptCatalog)
return runCatalogList(invocation, suite, input, name, suite.ListPrompts, encodePromptCatalog)
case "profiles":
return runCatalogList(invocation, suite, input, suite.ListProfiles, encodeProfileCatalog)
return runCatalogList(invocation, suite, input, name, suite.ListProfiles, encodeProfileCatalog)
case "backends":
return runCatalogList(invocation, suite, input, suite.ListBackends, encodeProfileCatalog)
return runCatalogList(invocation, suite, input, name, suite.ListBackends, encodeProfileCatalog)
case "namespaces":
list := func(ctx context.Context, request CatalogRequest, value I, state S) (commandkit.Acquired[[]string], error) {
return suite.ListNamespaces(ctx, NamespaceRequest{Selection: request.Selection, Kinds: input.kinds}, value, state)
}
return runCatalogList(invocation, suite, input, list, encodeProfileCatalog)
return runCatalogList(invocation, suite, input, name, list, encodeProfileCatalog)
case "collections":
list := func(ctx context.Context, request CatalogRequest, value I, state S) (commandkit.Acquired[[]promptkit.CollectionInfo], error) {
return suite.ListCollections(ctx, request.Selection, value, state)
}
return runCatalogList(invocation, suite, input, list, encodeCollectionCatalog)
return runCatalogList(invocation, suite, input, name, list, encodeCollectionCatalog)
default:
return definitionFailure(fmt.Errorf("unknown PromptKit catalog %q", name), nil)
return definitionFailure("Unable to list the selected PromptKit catalog.", commandkit.Explanation{}, fmt.Errorf("unknown PromptKit catalog %q", name))
}
},
})
@@ -385,12 +385,13 @@ func runCatalogList[T any, I, S any](
invocation commandkit.Invocation,
suite Suite[I, S],
input *catalogInput[I],
catalog string,
list func(context.Context, CatalogRequest, I, S) (commandkit.Acquired[T], error),
encode func(T) ([]byte, error),
) commandkit.Outcome {
if invocation.Context != nil {
if err := invocation.Context.Err(); err != nil {
return definitionFailure(err, nil)
return definitionFailure("PromptKit catalog listing was canceled.", commandkit.Explanation{}, err)
}
}
var state S
@@ -399,30 +400,29 @@ func runCatalogList[T any, I, S any](
}
if invocation.Context != nil {
if err := invocation.Context.Err(); err != nil {
return definitionFailure(err, nil)
return definitionFailure("PromptKit catalog listing was canceled.", commandkit.Explanation{}, err)
}
}
acquired, err := list(invocation.Context, CatalogRequest{Selection: input.selection, Filter: input.filter}, input.selected, state)
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
if err != nil {
return definitionFailure(err, acquired.Diagnostics)
return definitionFailure(fmt.Sprintf("Unable to list PromptKit %s.", catalog), acquired.Explanation, err)
}
if invocation.Context != nil {
if err := invocation.Context.Err(); err != nil {
return definitionFailure(err, acquired.Diagnostics)
return definitionFailure("PromptKit catalog listing was canceled.", acquired.Explanation, err)
}
}
output, err := encode(acquired.Value)
if err != nil {
return definitionFailure(err, acquired.Diagnostics)
}
return writePromptReport(invocation, output, acquired.Diagnostics)
return renderPreparedPromptReport(invocation, acquired.Explanation, func() ([]byte, error) {
return encode(acquired.Value)
})
}
func runDefinitionInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *definitionInput[I], prompt bool) commandkit.Outcome {
if invocation.Context != nil {
if err := invocation.Context.Err(); err != nil {
return definitionFailure(err, nil)
return definitionFailure("PromptKit inspection was canceled.", commandkit.Explanation{}, err)
}
}
var state S
@@ -431,41 +431,43 @@ func runDefinitionInspection[I, S any](invocation commandkit.Invocation, suite S
}
if invocation.Context != nil {
if err := invocation.Context.Err(); err != nil {
return definitionFailure(err, nil)
return definitionFailure("PromptKit inspection was canceled.", commandkit.Explanation{}, err)
}
}
if prompt {
request := PromptRequest{Selection: input.selection, ID: input.id}
acquired, err := suite.InspectPrompt(invocation.Context, request, input.selected, state)
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
if err != nil {
return definitionFailure(err, acquired.Diagnostics)
return definitionFailure(fmt.Sprintf("Unable to inspect PromptKit prompt %q.", input.id), acquired.Explanation, err)
}
if acquired.Value == nil {
return definitionFailure(errors.New("PromptKit prompt inspection returned nil"), acquired.Diagnostics)
return definitionFailure("PromptKit prompt inspection returned no result.", acquired.Explanation, nil)
}
if invocation.Context != nil {
if err := invocation.Context.Err(); err != nil {
return definitionFailure(err, acquired.Diagnostics)
return definitionFailure(fmt.Sprintf("Inspection of PromptKit prompt %q was canceled.", input.id), acquired.Explanation, err)
}
}
return renderPromptReport(invocation, promptReportFromInspection(acquired.Value), input.format, acquired.Diagnostics)
return renderPromptReport(invocation, acquired.Value, input.format, acquired.Explanation)
}
request := ProfileRequest{Selection: input.selection, ID: input.id}
acquired, err := suite.InspectProfile(invocation.Context, request, input.selected, state)
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
if err != nil {
return definitionFailure(err, acquired.Diagnostics)
return definitionFailure(fmt.Sprintf("Unable to inspect PromptKit profile %q.", input.id), acquired.Explanation, err)
}
if acquired.Value == nil {
return definitionFailure(errors.New("PromptKit profile inspection returned nil"), acquired.Diagnostics)
return definitionFailure("PromptKit profile inspection returned no result.", acquired.Explanation, nil)
}
if invocation.Context != nil {
if err := invocation.Context.Err(); err != nil {
return definitionFailure(err, acquired.Diagnostics)
return definitionFailure(fmt.Sprintf("Inspection of PromptKit profile %q was canceled.", input.id), acquired.Explanation, err)
}
}
return renderProfileReport(invocation, profileReportFromInspection(acquired.Value), input.format, acquired.Diagnostics)
return renderProfileReport(invocation, acquired.Value, input.format, acquired.Explanation)
}
type promptInputReport struct {
@@ -624,7 +626,7 @@ func profileReportFromInspection(inspection *promptkit.ProfileInspection) profil
func runRequirementsInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *requirementsInput[I]) commandkit.Outcome {
if invocation.Context != nil {
if err := invocation.Context.Err(); err != nil {
return definitionFailure(err, nil)
return definitionFailure("PromptKit requirements inspection was canceled.", commandkit.Explanation{}, err)
}
}
var state S
@@ -633,56 +635,69 @@ func runRequirementsInspection[I, S any](invocation commandkit.Invocation, suite
}
if invocation.Context != nil {
if err := invocation.Context.Err(); err != nil {
return definitionFailure(err, nil)
return definitionFailure("PromptKit requirements inspection was canceled.", commandkit.Explanation{}, err)
}
}
request := RequirementsRequest{Selection: input.selection, Inspection: input.inspection}
acquired, err := suite.InspectRequirements(invocation.Context, request, input.selected, state)
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
if err != nil {
return definitionFailure(err, acquired.Diagnostics)
return definitionFailure(fmt.Sprintf("Unable to inspect requirements for PromptKit prompt %q.", input.inspection.PromptID), acquired.Explanation, err)
}
if acquired.Value == nil {
return definitionFailure(errors.New("PromptKit requirements inspection returned nil"), acquired.Diagnostics)
return definitionFailure("PromptKit requirements inspection returned no result.", acquired.Explanation, nil)
}
if invocation.Context != nil {
if err := invocation.Context.Err(); err != nil {
return definitionFailure(err, acquired.Diagnostics)
return definitionFailure(fmt.Sprintf("Requirements inspection for PromptKit prompt %q was canceled.", input.inspection.PromptID), acquired.Explanation, err)
}
}
output, err := encodePromptYAMLBytes(requirementsReportFromInspection(acquired.Value))
if err != nil {
return definitionFailure(err, acquired.Diagnostics)
}
return writePromptReport(invocation, output, acquired.Diagnostics)
return renderPreparedPromptReport(invocation, acquired.Explanation, func() ([]byte, error) {
return encodePromptYAMLBytes(requirementsReportFromInspection(acquired.Value))
})
}
func renderPromptReport(invocation commandkit.Invocation, report promptInspectionReport, format string, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
var output bytes.Buffer
if format == "yaml" {
if err := encodePromptYAML(&output, report); err != nil {
return definitionFailure(err, diagnostics)
func renderPromptReport(invocation commandkit.Invocation, inspection *promptkit.PromptInspection, format string, explanation commandkit.Explanation) commandkit.Outcome {
return renderPreparedPromptReport(invocation, explanation, func() ([]byte, error) {
report := promptReportFromInspection(inspection)
if format == "yaml" {
return encodePromptYAMLBytes(report)
}
} else {
output.WriteString(formatPromptText(report))
}
return writePromptReport(invocation, output.Bytes(), diagnostics)
return []byte(formatPromptText(report)), nil
})
}
func renderProfileReport(invocation commandkit.Invocation, report profileInspectionReport, format string, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
var output bytes.Buffer
if format == "yaml" {
if err := encodePromptYAML(&output, report); err != nil {
return definitionFailure(err, diagnostics)
func renderProfileReport(invocation commandkit.Invocation, inspection *promptkit.ProfileInspection, format string, explanation commandkit.Explanation) commandkit.Outcome {
return renderPreparedPromptReport(invocation, explanation, func() ([]byte, error) {
report := profileReportFromInspection(inspection)
if format == "yaml" {
return encodePromptYAMLBytes(report)
}
} else {
text, err := formatProfileText(report)
return []byte(text), err
})
}
// These reports were already prepared before delivery. Keep that boundary
// inside managed rendering, with cancellation owned by the command outcome.
func renderPreparedPromptReport(invocation commandkit.Invocation, explanation commandkit.Explanation, prepare func() ([]byte, error)) commandkit.Outcome {
var canceled error
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
output, err := prepare()
if err != nil {
return definitionFailure(err, diagnostics)
return err
}
output.WriteString(text)
if canceled = promptKitContextError(invocation.Context); canceled != nil {
return nil
}
_, err = writer.Write(output)
return err
})
if canceled != nil {
return definitionFailure("PromptKit report preparation was canceled.", explanation, canceled)
}
return writePromptReport(invocation, output.Bytes(), diagnostics)
return commandkit.Success()
}
func formatPromptText(report promptInspectionReport) string {
@@ -771,30 +786,33 @@ func encodePromptYAMLBytes(value any) ([]byte, error) {
return output.Bytes(), nil
}
func writePromptReport(invocation commandkit.Invocation, output []byte, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
if invocation.Context != nil {
if err := invocation.Context.Err(); err != nil {
return definitionFailure(err, diagnostics)
func queuePromptDiagnostics(reporter *commandkit.Reporter, diagnostics []commandkit.Diagnostic) {
deferred := reporter.AtEnd()
for _, diagnostic := range diagnostics {
if diagnostic.Write == nil {
continue
}
switch diagnostic.Severity {
case commandkit.SeverityInfo:
_ = deferred.Info("", diagnostic.Write)
case commandkit.SeverityDebug:
_ = deferred.Debug("", diagnostic.Write)
case commandkit.SeverityProgress:
_ = reporter.Progress("", diagnostic.Write)
default:
_ = deferred.Warn("", diagnostic.Write)
}
}
written, err := invocation.Streams.Out.Write(output)
if err == nil && written != len(output) {
err = io.ErrShortWrite
}
if err != nil {
outcome := definitionFailure(err, diagnostics)
outcome.ReportIncomplete = true
outcome.DeliveryError = err
return outcome
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
}
func definitionFailure(cause error, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
outcome := commandkit.Failure(cause)
outcome.Diagnostics = diagnostics
func definitionFailure(message string, explanation commandkit.Explanation, cause error) commandkit.Outcome {
outcome := commandkit.Failure(message)
if explanation.Message != "" || len(explanation.Details) > 0 {
outcome = outcome.WithExplanation(explanation.Message, explanation.Details...)
}
if cause != nil {
outcome = outcome.WithCause(cause)
}
return outcome
}

View File

@@ -16,9 +16,9 @@ import (
"go.yaml.in/yaml/v4"
)
func newPromptTestApp(t *testing.T, commands ...commandkit.Command) *commandkit.App {
func newPromptTestApp(t *testing.T, declarations ...commandkit.Declaration) *commandkit.App {
t.Helper()
app, err := commandkit.New(commandkit.Identity{Name: "example"}, commands...)
app, err := commandkit.New(commandkit.Identity{Name: "example"}, declarations...)
if err != nil {
t.Fatal(err)
}
@@ -255,7 +255,7 @@ func TestCatalogCommandTreesAreIndependentAndOmitShow(t *testing.T) {
app := newPromptTestApp(t, test.cmd)
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"help", "promptkit", "list"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "list") || !strings.Contains(output.String(), test.want) || strings.Contains(output.String(), test.omit) || strings.Contains(output.String(), "show") {
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "list") || !strings.Contains(output.String(), test.want) || strings.Contains(output.String(), "\n "+test.omit+" ") || strings.Contains(output.String(), "\n show ") {
t.Fatalf("catalog-only help = (%#v, %q)", outcome, output.String())
}
})
@@ -701,41 +701,39 @@ func TestCatalogRejectsOperandsAndUnavailableSelectors(t *testing.T) {
}
}
func TestCatalogFailuresRetainDiagnosticsAndDeliveryState(t *testing.T) {
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "catalog warning\n")
return err
}}
hasCatalogWarning := func(diagnostics []commandkit.Diagnostic) bool {
for _, got := range diagnostics {
if got.Severity != diagnostic.Severity || got.Write == nil {
continue
}
var output bytes.Buffer
if got.Write(&output) == nil && output.String() == "catalog warning\n" {
return true
}
}
return false
}
func TestCatalogFailureReportsWarningAndNativeExplanationSeparately(t *testing.T) {
diagnostic := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "catalog warning")
providerErr := errors.New("catalog provider failed")
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ListPrompts: func(context.Context, CatalogRequest, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, providerErr
return commandkit.Acquired[[]promptkit.PromptIdentity]{
Diagnostics: []commandkit.Diagnostic{diagnostic},
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
_, err := io.WriteString(writer, "PromptKit lookup context\n")
return err
}}},
}, providerErr
}}))
outcome := app.Execute(context.Background(), []string{"promptkit", "list", "prompts"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, providerErr) || !hasCatalogWarning(outcome.Diagnostics) {
var output, diagnosticOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "list", "prompts", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, providerErr) {
t.Fatalf("provider failure outcome = %#v", outcome)
}
warningAt := strings.Index(diagnosticOutput.String(), "catalog warning")
explanationAt := strings.Index(diagnosticOutput.String(), "PromptKit lookup context")
if warningAt < 0 || explanationAt < 0 || warningAt >= explanationAt || strings.Count(diagnosticOutput.String(), "catalog warning") != 1 {
t.Fatalf("warning/native terminal context were not independently ordered once: %q", diagnosticOutput.String())
}
ctx, cancel := context.WithCancel(context.Background())
canceled := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ListPrompts: func(context.Context, CatalogRequest, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
cancel()
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "briefing", Version: testStringPointer("1")}}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
}}))
var output bytes.Buffer
outcome = canceled.Execute(ctx, []string{"promptkit", "list", "prompts"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !hasCatalogWarning(outcome.Diagnostics) || output.Len() != 0 {
t.Fatalf("post-provider cancellation outcome = %#v output=%q", outcome, output.String())
output.Reset()
diagnosticOutput.Reset()
outcome = canceled.Execute(ctx, []string{"promptkit", "list", "prompts", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !strings.Contains(diagnosticOutput.String(), "catalog warning") || output.Len() != 0 {
t.Fatalf("post-provider cancellation outcome = %#v output=%q err=%q", outcome, output.String(), diagnosticOutput.String())
}
writeErr := errors.New("catalog report writer failed")
@@ -752,13 +750,96 @@ func TestCatalogFailuresRetainDiagnosticsAndDeliveryState(t *testing.T) {
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "briefing", Version: testStringPointer("1")}}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
}}))
outcome := writerApp.Execute(context.Background(), []string{"promptkit", "list", "prompts"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, test.want) || !errors.Is(outcome.Cause, test.want) || !outcome.ReportIncomplete || !hasCatalogWarning(outcome.Diagnostics) {
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("catalog delivery outcome = %#v", outcome)
}
})
}
}
func TestCatalogAndInspectionRespectSuccessfulReportingModes(t *testing.T) {
for _, mode := range []string{"ordinary", "quiet", "verbose", "debug"} {
t.Run(mode, func(t *testing.T) {
calls := 0
suite := Suite[struct{}, struct{}]{
ListPrompts: func(context.Context, CatalogRequest, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
calls++
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "briefing"}}}, nil
},
InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
calls++
return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{PromptID: "briefing"}}, nil
},
}
app := newPromptTestApp(t, Command(suite))
args := []string{"promptkit", "list", "prompts"}
if mode != "ordinary" {
args = append(args, "--"+mode)
}
var output, diagnosticOutput bytes.Buffer
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeSuccess || calls != 1 {
t.Fatalf("%s listing = %#v after %d calls", mode, outcome, calls)
}
if mode == "quiet" {
if output.Len() != 0 {
t.Fatalf("quiet listing wrote result output %q", output.String())
}
} else if !strings.Contains(output.String(), "briefing") {
t.Fatalf("%s listing omitted result: %q", mode, output.String())
}
if mode == "debug" && !strings.Contains(diagnosticOutput.String(), "DEBUG:") {
t.Fatalf("debug listing omitted core debug events: %q", diagnosticOutput.String())
}
output.Reset()
diagnosticOutput.Reset()
args = []string{"promptkit", "show", "prompt", "briefing"}
if mode != "ordinary" {
args = append(args, "--"+mode)
}
outcome = app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeSuccess || calls != 2 {
t.Fatalf("%s inspection = %#v after %d callbacks", mode, outcome, calls)
}
if mode == "quiet" {
if output.Len() != 0 {
t.Fatalf("quiet inspection wrote result output %q", output.String())
}
} else if !strings.Contains(output.String(), "Prompt ID: \"briefing\"") {
t.Fatalf("%s inspection omitted result: %q", mode, output.String())
}
})
}
}
func TestQuietInspectionStillFindsReportEncodingFailure(t *testing.T) {
called := false
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{InspectProfile: func(context.Context, ProfileRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
called = true
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: &promptkit.ProfileInspection{
ProfileID: "broken",
EffectiveModelParams: promptkit.ExecutionTarget{ExtraParams: map[string]any{"unsupported": func() {}}},
}}, nil
}}))
result := &countingWriter{}
var diagnosticOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "show", "profile", "broken", "--quiet"}, commandkit.Streams{Out: result, Err: &diagnosticOutput})
if !called || result.calls != 0 {
t.Fatalf("quiet rendering called=%t destination writes=%d", called, result.calls)
}
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Presentation.Output.RenderError == nil {
t.Fatalf("quiet serialization failure outcome = %#v diagnostics=%q", outcome, diagnosticOutput.String())
}
}
type countingWriter struct{ calls int }
func (writer *countingWriter) Write(p []byte) (int, error) {
writer.calls++
return len(p), nil
}
func TestCommandUsesInstalledNamespaceCollisionChecks(t *testing.T) {
command := Command(Suite[struct{}, struct{}]{InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
return commandkit.Acquired[*promptkit.PromptInspection]{}, nil
@@ -1008,7 +1089,7 @@ func TestNativeLookupErrorsAndBlankIDsReachCallbacks(t *testing.T) {
}
output.Reset()
diagnosticOutput.Reset()
outcome = app.Execute(context.Background(), []string{"promptkit", "show", "prompt", ""}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
outcome = app.Execute(context.Background(), []string{"promptkit", "show", "prompt", "", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, nativeErr) || calls != 1 || output.Len() != 0 || !strings.Contains(diagnosticOutput.String(), "lookup context") {
t.Fatalf("native lookup failure = (%#v, calls=%d, out=%q, err=%q)", outcome, calls, output.String(), diagnosticOutput.String())
}
@@ -1055,7 +1136,7 @@ func TestDefinitionFormatPreflightAndReportDeliveryFailure(t *testing.T) {
writeErr := errors.New("report writer failed")
outcome = app.Execute(context.Background(), []string{"promptkit", "show", "profile", "default"}, commandkit.Streams{Out: partialReportWriter{err: writeErr}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, writeErr) || outcome.DeliveryError != writeErr || !outcome.ReportIncomplete || calls != 1 {
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || calls != 1 {
t.Fatalf("report delivery outcome = %#v calls=%d", outcome, calls)
}
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"io"
"strings"
"testing"
"testing/fstest"
@@ -66,6 +67,80 @@ func TestPublicDefinitionCommandsCaptureSelectorHandles(t *testing.T) {
}
}
func TestPromptKitAliasesComposeWithInstalledOperations(t *testing.T) {
type input struct{ Campaign commandkit.Supplied[string] }
selector := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign"}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(target *input, value commandkit.Supplied[string]) { target.Campaign = value })
var collectionInput input
var inspectionInput input
var inspectedID string
var smokeInput input
var smokeKind promptkit.SmokeTestKind
callbacks := 0
suite := ckprompt.Suite[input, struct{}]{
Selectors: []commandkit.Flag[input]{selector},
ListCollections: func(_ context.Context, _ ckconfig.Selection, value input, _ struct{}) (commandkit.Acquired[[]promptkit.CollectionInfo], error) {
callbacks++
collectionInput = value
return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: []promptkit.CollectionInfo{{Namespace: "spring"}}}, nil
},
InspectPrompt: func(_ context.Context, request ckprompt.PromptRequest, value input, _ struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
callbacks++
inspectionInput, inspectedID = value, request.ID
return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{PromptID: request.ID}}, nil
},
SmokeTest: func(_ context.Context, request ckprompt.SmokeRequest, value input, _ struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
callbacks++
smokeInput, smokeKind = value, request.Request.Kind
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind, Supported: true, Completed: true}}, nil
},
}
app, err := commandkit.New(commandkit.Identity{Name: "example"},
ckprompt.Command(suite),
commandkit.Alias("collect", "promptkit list collections"),
commandkit.Alias("inspect", "promptkit show prompt"),
commandkit.Alias("smoke", "promptkit test"),
commandkit.FlagAliasFor("promptkit list collections", "--camp", "--campaign"),
commandkit.FlagAliasFor("promptkit show prompt", "--camp", "--campaign"),
commandkit.FlagAlias("--hlep", "--help"),
)
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"collect", "--camp=spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !collectionInput.Campaign.Set || collectionInput.Campaign.Value != "spring" || !strings.Contains(output.String(), "spring") {
t.Fatalf("collection shortcut outcome/input/report = (%#v, %#v, %q)", outcome, collectionInput, output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"inspect", "briefing", "--camp", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !inspectionInput.Campaign.Set || inspectionInput.Campaign.Value != "spring" || inspectedID != "briefing" {
t.Fatalf("inspection shortcut outcome/input/id = (%#v, %#v, %q)", outcome, inspectionInput, inspectedID)
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"smoke", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--help") || callbacks != 2 {
t.Fatalf("global help alias outcome/report/callbacks = (%#v, %q, %d)", outcome, output.String(), callbacks)
}
if err := app.Register(commandkit.Alias("promptkit test text-inference", "promptkit test authentication")); err == nil {
t.Fatal("accepted a command alias that maps one smoke operand to another")
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"promptkit", "test", "authentication", "--campaign=spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || smokeKind != promptkit.SmokeTestAuthentication || !smokeInput.Campaign.Set || smokeInput.Campaign.Value != "spring" || !strings.Contains(output.String(), "kind: authentication") || !strings.Contains(output.String(), "completed: true") {
t.Fatalf("canonical smoke outcome/request/report = (%#v, %#v, %q)", outcome, smokeKind, output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"smoke", "text-inference", "--campaign=spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || smokeKind != promptkit.SmokeTestKind("text-inference") || callbacks != 4 || !strings.Contains(output.String(), "kind: text-inference") {
t.Fatalf("smoke shortcut outcome/kind/report/callbacks = (%#v, %#v, %q, %d)", outcome, smokeKind, output.String(), callbacks)
}
}
func TestPublicConfigurationAndDefinitionFamiliesRemainIndependent(t *testing.T) {
inputs, err := promptkit.FilesystemInputs("composition fixture", fstest.MapFS{
"config/prompts.yml": &fstest.MapFile{Data: []byte(`prompts:

View File

@@ -1,5 +1,10 @@
// Package ckprompt provides optional PromptKit configuration comparison,
// prompt catalogs, named inspections, requirements discovery and validation,
// and native smoke testing for CommandKit. Importing ckprompt installs neither
// family; callers compose its declarations with core and ckconfig explicitly.
// and native smoke testing for CommandKit. Its command factories return
// canonical commandkit.Command declarations. Applications can install
// commandkit.RoutingRule aliases alongside them through commandkit.New or
// App.Register. Importing ckprompt installs neither family; callers compose its
// declarations with core and ckconfig explicitly. Provider diagnostics remain
// passive until the owning command submits them through the invocation
// reporter, separately from any terminal explanation.
package ckprompt

View File

@@ -15,8 +15,11 @@ go run . promptkit list profiles --config fixtures/after.yml --campaign autumn -
go run . promptkit list backends --campaign spring
go run . promptkit list namespaces --campaign spring
go run . promptkit list collections --campaign spring
go run . list collections --campaign spring
go run . pk list bundles --campaign spring
go run . promptkit list prompts --namespace field-notes --campaign spring
go run . promptkit show prompt briefing --campaign spring --format yaml
go run . pk show prompt briefing --campaign spring --fmt yaml
go run . promptkit show profile spring --campaign spring
go run . promptkit requirements show briefing --campaign spring
go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml
@@ -24,10 +27,27 @@ go run . promptkit requirements validate briefing --campaign spring --input topi
go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt --var mode=full --input notes=fixtures/notes.txt --format yaml
go run . promptkit requirements validate briefing --campaign spring
go run . promptkit test text_inference --campaign spring --prompt briefing --profile spring
go run . smoke text_inference --campaign spring --prompt briefing --profile spring
go run . promptkit test all --campaign spring --prompt briefing --profile spring
go run . pk --hlep
go run . session restore session-42 --campaign spring
```
`newApp` installs application-owned command and flag aliases alongside the
complete `ckconfig` and `ckprompt` declarations. `list collections` and
`promptkit list bundles` both reach the canonical collection command; `pk`
shortens the PromptKit group. `--fmt` is scoped to named prompt inspection and
`--hlep` is available globally. The `smoke` shortcut retains the canonical
`promptkit test KIND` operand, including the special `all` aggregate.
The composition calls `ckconfig.Command(suite)` and `ckprompt.Command(definitions)`
directly in a `[]commandkit.Declaration` with root-installed routing rules.
Group children remain canonical `Command` values. For command-only slices,
convert each `Command` to `Declaration` before appending aliases. The
[consumer guide](../../../../docs/consumers/guide.md#application-owned-aliases)
includes minimal direct-call and slice-conversion examples; this README documents
the maintained complete consumer.
The sources and session store are small in-memory fixtures. `--campaign` is a
typed selector shared by the app and PromptKit configuration scopes. Their
`ckconfig.Suite[*inspectionInput, *runCache]` uses `NewInput` to initialize each
@@ -92,3 +112,12 @@ required `topic` artifact is not supplied. The builder defaults variables from
the selected campaign but leaves artifact bindings to the operator. The
`requirements show` commands remain discovery reports: partial discovery can
succeed even when a concrete request would fail validation.
The alias examples use the local CommandKit source because the composed
module's published core dependency, v0.2.0, predates alias declarations. From
the repository root, add the three local modules to `go.work` as described in
the [developer guide](../../../../docs/development.md#documentation-validation),
then run `go test ./...` from this example directory. This checks the example
against the current local core and integration sources; it does not claim that
published v0.2.0 supports aliases. The next core dependency adoption is handled
after the core release is prepared.

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"strconv"
@@ -65,6 +66,8 @@ type session struct {
Summary string
}
var errSessionNotFound = errors.New("saved session was not found")
func campaignSelector() commandkit.Flag[*inspectionInput] {
return commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Usage: "Select campaign inputs."}, (*string)(nil), parseString,
func(input **inspectionInput, value commandkit.Supplied[string]) { (*input).Campaign = value })
@@ -200,6 +203,14 @@ func newApp() (*commandkit.App, error) {
}
func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
return newAppWithEngineLoader(client, loadPromptEngine, nil)
}
func newAppWithEngineLoader(
client promptkit.LLMClient,
engineLoader func(context.Context, ckconfig.Selection, *inspectionInput, *runCache) (*promptkit.Engine, error),
smokeTest func(context.Context, ckprompt.SmokeRequest, *inspectionInput, *runCache) (commandkit.Acquired[*promptkit.SmokeTestReport], error),
) (*commandkit.App, error) {
if client == nil {
client = &exampleLLMClient{}
}
@@ -210,7 +221,7 @@ func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
stateFactory := func() *runCache { return &runCache{llmClient: client} }
appScope := ckconfig.FromConfigKit("app", "Application settings.", loadApplicationConfig)
engineScope := ckprompt.ConfigScope(loadPromptEngine)
engineScope := ckprompt.ConfigScope(engineLoader)
configCommand := ckconfig.Command(ckconfig.Suite[*inspectionInput, *runCache]{
NewInput: inputFactory,
NewState: stateFactory,
@@ -227,12 +238,15 @@ func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
Scopes: []ckconfig.Scope[*inspectionInput, *runCache]{appScope, engineScope},
})
definitions := ckprompt.FromEngine(loadPromptEngine)
definitions := ckprompt.FromEngine(engineLoader)
definitions.NewInput = inputFactory
definitions.NewState = stateFactory
definitions.Selectors = []commandkit.Flag[*inspectionInput]{campaignSelector()}
definitions = ckprompt.WithRequestValidation(definitions, loadPromptEngine, buildValidationRequest)
definitions = ckprompt.WithSmokeTesting(definitions, loadPromptEngine, nil)
definitions = ckprompt.WithRequestValidation(definitions, engineLoader, buildValidationRequest)
definitions = ckprompt.WithSmokeTesting(definitions, engineLoader, nil)
if smokeTest != nil {
definitions.SmokeTest = smokeTest
}
definitionCommand := ckprompt.Command(definitions)
store := map[string]session{
@@ -246,36 +260,45 @@ func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
Arguments: []commandkit.Argument[restoreInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "Session identifier."}, true, parseString,
func(input *restoreInput, value commandkit.Supplied[string]) { input.Session = value.Value })},
Run: func(invocation commandkit.Invocation, input restoreInput) commandkit.Outcome {
reporter := invocation.Reporter()
if err := invocation.Context.Err(); err != nil {
return commandkit.Failure(err)
return commandkit.Failure("Session restore was canceled.").WithCause(err)
}
application, err := loadApplicationConfig(invocation.Context, ckconfig.Selection{}, nil, nil)
if err != nil {
return commandkit.Failure(err)
return commandkit.Failure("Could not load application configuration.").WithCause(err)
}
name, _, err := selectedCampaign(application.Config, input.Campaign)
if err != nil {
return commandkit.Failure(err)
return commandkit.Failure(err.Error()).WithCause(err)
}
record, ok := store[input.Session]
if !ok {
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 4, Domain: "session_not_found", Cause: errors.New("saved session was not found")}
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 4, Domain: "session_not_found", Cause: errSessionNotFound, Explanation: commandkit.Explanation{Message: "Saved session was not found."}}
}
if record.Campaign != name {
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 3, Domain: "campaign_mismatch", Cause: fmt.Errorf("session belongs to campaign %q", record.Campaign)}
}
if _, err := fmt.Fprintf(invocation.Streams.Out, "restored %s: %s\n", record.ID, record.Summary); err != nil {
return commandkit.Failure(err)
cause := fmt.Errorf("session belongs to campaign %q", record.Campaign)
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 3, Domain: "campaign_mismatch", Cause: cause, Explanation: commandkit.Explanation{Message: "Session campaign does not match the selected campaign."}}
}
_ = reporter.RenderOutput(func(writer io.Writer) error {
_, err := fmt.Fprintf(writer, "restored %s: %s\n", record.ID, record.Summary)
return err
})
return commandkit.Success()
},
})
return commandkit.New(
commandkit.Identity{Name: "composed-example", Version: "1.0"},
declarations := []commandkit.Declaration{
configCommand,
definitionCommand,
commandkit.Group("session", "Manage saved sessions.", restore),
)
commandkit.Alias("list collections", "promptkit list collections"),
commandkit.Alias("promptkit list bundles", "promptkit list collections"),
commandkit.Alias("pk", "promptkit"),
commandkit.Alias("smoke", "promptkit test"),
commandkit.FlagAliasFor("promptkit show prompt", "--fmt", "--format"),
commandkit.FlagAlias("--hlep", "--help"),
}
return commandkit.New(commandkit.Identity{Name: "composed-example", Version: "1.0"}, declarations...)
}
func parseString(raw string) (string, error) { return raw, nil }

View File

@@ -3,8 +3,10 @@ package main
import (
"bytes"
"context"
"errors"
"io"
"reflect"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
@@ -350,6 +352,119 @@ func TestComposedCatalogsAndSmokeUseInjectedClientOnlyForInference(t *testing.T)
}
}
func TestComposedSmokeReportSurvivesInferenceFailure(t *testing.T) {
wantCause := errors.New("local example inference failed")
smokeTest := func(_ context.Context, request ckprompt.SmokeRequest, _ *inspectionInput, _ *runCache) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
return commandkit.Acquired[*promptkit.SmokeTestReport]{
Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind, Completed: true},
Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent smoke warning")},
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
_, err := io.WriteString(writer, "Native text inference context.\n")
return err
}}},
}, wantCause
}
app, err := newAppWithEngineLoader(&exampleLLMClient{}, loadPromptEngine, smokeTest)
if err != nil {
t.Fatal(err)
}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "text_inference", "--campaign", "spring", "--prompt", "briefing", "--profile", "spring", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, wantCause) {
t.Fatalf("smoke failure outcome = %#v; report %q; diagnostics %q", outcome, output.String(), diagnostics.String())
}
if !strings.Contains(output.String(), "kind: text_inference") || !strings.Contains(output.String(), "completed: true") {
t.Fatalf("native smoke report was not delivered: %q", output.String())
}
warningAt := strings.Index(diagnostics.String(), "independent smoke warning")
contextAt := strings.Index(diagnostics.String(), "Native text inference context")
if warningAt < 0 || contextAt < 0 || warningAt >= contextAt || strings.Contains(diagnostics.String(), wantCause.Error()) {
t.Fatalf("terminal explanation/diagnostics = %q / %q", outcome.Explanation.Message, diagnostics.String())
}
if outcome.Explanation.Message != "" {
t.Errorf("unexpected synthesized terminal explanation %q", outcome.Explanation.Message)
}
}
func TestComposedCustomRestoreUsesSharedReportingAndTerminalFailure(t *testing.T) {
app, err := newApp()
if err != nil {
t.Fatal(err)
}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"session", "restore", "session-42", "--campaign", "spring", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeSuccess || output.Len() != 0 || diagnostics.Len() != 0 {
t.Fatalf("quiet custom command outcome/output/diagnostics = %#v / %q / %q", outcome, output.String(), diagnostics.String())
}
output.Reset()
diagnostics.Reset()
outcome = app.Execute(context.Background(), []string{"session", "restore", "missing", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeDomain || outcome.Status != 4 || outcome.Domain != "session_not_found" || !errors.Is(outcome.Cause, errSessionNotFound) {
t.Fatalf("missing session outcome = %#v", outcome)
}
if output.Len() != 0 || outcome.Explanation.Message == "" || !strings.Contains(diagnostics.String(), outcome.Explanation.Message) {
t.Fatalf("terminal failure output/explanation/diagnostics = %q / %q / %q", output.String(), outcome.Explanation.Message, diagnostics.String())
}
if strings.Contains(diagnostics.String(), errSessionNotFound.Error()) {
t.Errorf("terminal presentation exposed the underlying cause: %q", diagnostics.String())
}
}
func TestComposedAliasesReachCanonicalCommands(t *testing.T) {
clientCalls := 0
engineLoads := 0
engineLoader := func(ctx context.Context, selection ckconfig.Selection, input *inspectionInput, cache *runCache) (*promptkit.Engine, error) {
engineLoads++
return loadPromptEngine(ctx, selection, input, cache)
}
app, err := newAppWithEngineLoader(&exampleLLMClient{calls: &clientCalls}, engineLoader, nil)
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"pk", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--hlep → --help") || strings.Contains(output.String(), "field-notes") || engineLoads != 0 || clientCalls != 0 {
t.Fatalf("group help alias outcome/output/engine loads/client calls = (%#v, %q, %d, %d)", outcome, output.String(), engineLoads, clientCalls)
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"pk", "--hlep", "--quiet"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || output.Len() != 0 || engineLoads != 0 || clientCalls != 0 {
t.Fatalf("quiet aliased help outcome/output/engine loads/client calls = (%#v, %q, %d, %d)", outcome, output.String(), engineLoads, clientCalls)
}
for _, args := range [][]string{
{"list", "collections", "--campaign", "spring"},
{"pk", "list", "bundles", "--campaign", "spring"},
{"promptkit", "list", "collections", "--campaign", "spring"},
} {
output.Reset()
outcome = app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "field-notes") {
t.Fatalf("collection route %q outcome/report = (%#v, %q)", args, outcome, output.String())
}
}
for _, args := range [][]string{
{"pk", "show", "prompt", "briefing", "--campaign", "spring", "--fmt", "yaml"},
{"promptkit", "show", "prompt", "briefing", "--campaign", "spring", "--format", "yaml"},
} {
output.Reset()
outcome = app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "prompt_id: briefing") {
t.Fatalf("prompt inspection route %q outcome/report = (%#v, %q)", args, outcome, output.String())
}
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"smoke", "text_inference", "--campaign", "spring", "--prompt", "briefing", "--profile", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "kind: text_inference") || !strings.Contains(output.String(), "completed: true") || clientCalls != 1 {
t.Fatalf("smoke shortcut outcome/report/client calls = (%#v, %q, %d)", outcome, output.String(), clientCalls)
}
}
func composedReportValue(t *testing.T, data []byte, category string, fields ...string) *yaml.Node {
t.Helper()
var document yaml.Node

View File

@@ -1,3 +1,7 @@
gitea.maximumdirect.net/eric/CommandKit v0.2.0 h1:UNBjSq6XDZHdPqvZLOL3N5R1E1ol0jnfldgradU7eqw=
gitea.maximumdirect.net/eric/CommandKit v0.2.0/go.mod h1:47DQ6Mo/yZqUVn5PAFRmPck91Zhf1jfaHDMoEZvUzsQ=
gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig v0.1.0 h1:qqgaCKrraUL//1vcoU+KUlQ5PNX0EtRpqsUraSiUW/w=
gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig v0.1.0/go.mod h1:SFtsgbTVQd+BCGQVCFSAopl55pGYhP1sSF442KFpSYA=
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
gitea.maximumdirect.net/eric/promptkit v0.16.0 h1:g3dhN0QfzQriaVOBmGUZCw2iBM9Mli2AAU4CbMfzcuY=

View File

@@ -0,0 +1,80 @@
package ckprompt
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
promptkit "gitea.maximumdirect.net/eric/promptkit"
)
func TestBundledNativeFailuresReachTerminalExplanation(t *testing.T) {
engine := adapterEngine(t)
load := func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
return engine, nil
}
for _, operation := range []string{"validation", "inspection"} {
for _, mode := range []string{"ordinary", "quiet"} {
t.Run(operation+"/"+mode, func(t *testing.T) {
suite := FromEngine(load)
suite = WithRequestValidation(suite, load, nil)
var nativeErr error
args := []string{"promptkit", "requirements", "validate", "briefing"}
if operation == "validation" {
original := suite.ValidateRequest
suite.ValidateRequest = func(ctx context.Context, request ValidationRequest, input, state struct{}) (commandkit.Acquired[struct{}], error) {
acquired, err := original(ctx, request, input, state)
nativeErr = err
acquired.Diagnostics = append(acquired.Diagnostics, commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent fixture warning"))
return acquired, err
}
} else {
args = []string{"promptkit", "show", "prompt", "missing"}
original := suite.InspectPrompt
suite.InspectPrompt = func(ctx context.Context, request PromptRequest, input, state struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
acquired, err := original(ctx, request, input, state)
nativeErr = err
return acquired, err
}
}
if mode == "quiet" {
args = append(args, "--quiet")
}
var diagnostics bytes.Buffer
got := newPromptTestApp(t, Command(suite)).Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: &diagnostics})
if nativeErr == nil {
t.Fatal("fixture did not supply a native failure")
}
if got.Status != 1 || !errors.Is(got.Cause, nativeErr) || got.Explanation.Message != nativeErr.Error() {
t.Fatalf("native error was not preserved in outcome: %#v, error=%v", got, nativeErr)
}
if strings.Count(diagnostics.String(), nativeErr.Error()) != 1 || strings.Contains(diagnostics.String(), "independent fixture warning") {
t.Fatalf("terminal adaptation or warning visibility: %q", diagnostics.String())
}
})
}
}
}
func TestNativeOperationExplanationPreservesSuppliedContext(t *testing.T) {
nativeErr := errors.New("native operation detail")
for _, explanation := range []commandkit.Explanation{
{Message: "selected explanation"},
{Details: []commandkit.Detail{func(w io.Writer) error { _, err := io.WriteString(w, "native structured explanation\n"); return err }}},
} {
messages := (promptKitAcquisition{explanation: explanation}).withOperationError(nativeErr)
suite := Suite[struct{}, struct{}]{InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
return commandkit.Acquired[*promptkit.PromptInspection]{Explanation: messages.explanation}, nativeErr
}}
var diagnostics bytes.Buffer
got := newPromptTestApp(t, Command(suite)).Execute(context.Background(), []string{"promptkit", "show", "prompt", "fixture", "--quiet"}, commandkit.Streams{Err: &diagnostics})
if !errors.Is(got.Cause, nativeErr) || strings.Contains(diagnostics.String(), nativeErr.Error()) || strings.Count(diagnostics.String(), "explanation") != 1 {
t.Fatalf("supplied explanation was replaced or duplicated: %#v, %q", got, diagnostics.String())
}
}
}

View File

@@ -0,0 +1,50 @@
package ckprompt
import (
"context"
"errors"
"testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
promptkit "gitea.maximumdirect.net/eric/promptkit"
)
type cancelDuringYAML struct{ cancel context.CancelFunc }
func (value cancelDuringYAML) MarshalYAML() (any, error) {
value.cancel()
return "prepared", nil
}
func TestInspectionCancellationDuringPreparationPreventsDelivery(t *testing.T) {
for _, format := range []string{"text", "yaml"} {
for _, mode := range []string{"ordinary", "quiet"} {
t.Run(format+"/"+mode, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
InspectProfile: func(context.Context, ProfileRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: &promptkit.ProfileInspection{
ProfileID: "demo",
EffectiveModelParams: promptkit.ExecutionTarget{ExtraParams: map[string]any{
"controlled": cancelDuringYAML{cancel},
}},
}}, nil
},
}))
args := []string{"promptkit", "show", "profile", "demo", "--format", format}
if mode == "quiet" {
args = append(args, "--quiet")
}
output := &countingWriter{}
got := app.Execute(ctx, args, commandkit.Streams{Out: output})
if got.Kind != commandkit.OutcomeOperational || got.Status != 1 || !errors.Is(got.Cause, context.Canceled) || output.calls != 0 {
t.Fatalf("outcome=%#v writes=%d", got, output.calls)
}
if got.Presentation.Output.RenderError != nil || got.Presentation.Output.WriteError != nil {
t.Fatalf("operation cancellation became a presentation failure: %#v", got.Presentation.Output)
}
})
}
}
}

View File

@@ -319,12 +319,12 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{Issues: []promptkit.RequirementIssue{issue}}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
}}))
var report, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: &report, Err: &diagnostics})
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing", "--verbose"}, commandkit.Streams{Out: &report, Err: &diagnostics})
var decoded map[string]any
if err := yaml.Unmarshal(report.Bytes(), &decoded); err != nil {
t.Fatalf("decode partial report %q: %v", report.String(), err)
}
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || len(outcome.Diagnostics) != 1 || !strings.Contains(diagnostics.String(), "provider warning") || strings.Contains(diagnostics.String(), "native issue details") {
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete || !strings.Contains(diagnostics.String(), "provider warning") || strings.Contains(diagnostics.String(), "native issue details") {
t.Fatalf("partial inspection outcome=%#v report=%q diagnostics=%q", outcome, report.String(), diagnostics.String())
}
issues, ok := decoded["issues"].([]any)
@@ -339,8 +339,8 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, providerErr
}}))
var report, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: &report, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, providerErr) || len(outcome.Diagnostics) == 0 || !strings.Contains(diagnostics.String(), "provider warning") || report.Len() != 0 {
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing", "--verbose"}, commandkit.Streams{Out: &report, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, providerErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete || !strings.Contains(diagnostics.String(), "provider warning") || report.Len() != 0 {
t.Fatalf("provider failure outcome=%#v report=%q diagnostics=%q", outcome, report.String(), diagnostics.String())
}
})
@@ -351,9 +351,9 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
cancel()
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{Complete: true}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
}}))
var report bytes.Buffer
outcome := app.Execute(ctx, []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: &report, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || len(outcome.Diagnostics) == 0 || report.Len() != 0 {
var report, diagnostics bytes.Buffer
outcome := app.Execute(ctx, []string{"promptkit", "requirements", "show", "briefing", "--verbose"}, commandkit.Streams{Out: &report, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !strings.Contains(diagnostics.String(), "provider warning") || report.Len() != 0 {
t.Fatalf("post-provider cancellation = %#v report=%q", outcome, report.String())
}
})
@@ -363,7 +363,7 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
return commandkit.Acquired[*promptkit.RequirementInspection]{}, nil
}}))
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil || !strings.Contains(outcome.Cause.Error(), "returned nil") {
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Cause != nil || !strings.Contains(outcome.Explanation.Message, "returned no result") {
t.Fatalf("nil provider result = %#v", outcome)
}
})
@@ -387,7 +387,7 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{PromptID: "briefing"}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
}}))
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: writer, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, test.want) || !errors.Is(outcome.DeliveryError, test.want) || !outcome.ReportIncomplete || writer.output.Len() == 0 {
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || writer.output.Len() == 0 {
t.Fatalf("report delivery = %#v, partial=%q", outcome, writer.output.String())
}
})
@@ -401,8 +401,8 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{PromptID: "briefing"}, Diagnostics: []commandkit.Diagnostic{badDiagnostic}}, nil
}}))
var report bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: &report, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, diagnosticErr) || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || len(outcome.Diagnostics) == 0 || report.Len() == 0 {
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing", "--verbose"}, commandkit.Streams{Out: &report, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Diagnostics.RenderError, diagnosticErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || report.Len() == 0 {
t.Fatalf("diagnostic delivery outcome = %#v report=%q", outcome, report.String())
}
})

View File

@@ -26,8 +26,9 @@ type SmokeRequest struct {
// invocation context. Its complete returned request is passed to PromptKit;
// without a builder, the seed is forwarded unchanged. Reports and errors from
// PromptKit are both retained. A nil loader is kept as a suite wiring issue and
// does not install a smoke-test callback. The callback also retains engine
// loading diagnostics with the report and error.
// does not install a smoke-test callback. The callback also retains independent
// engine diagnostics and terminal context with the report and error. Native
// operation errors supply terminal explanations when structured context is absent.
func WithSmokeTesting[I, S any](
suite Suite[I, S],
load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error),
@@ -56,16 +57,17 @@ func WithSmokeTesting[I, S any](
return commandkit.Acquired[*promptkit.SmokeTestReport]{}, fmt.Errorf("PromptKit smoke test after building: %w", err)
}
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
if err != nil {
return commandkit.Acquired[*promptkit.SmokeTestReport]{Diagnostics: diagnostics}, err
return commandkit.Acquired[*promptkit.SmokeTestReport]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
if err := promptKitContextError(ctx); err != nil {
return commandkit.Acquired[*promptkit.SmokeTestReport]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit smoke test before native operation: %w", err)
return commandkit.Acquired[*promptkit.SmokeTestReport]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit smoke test before native operation: %w", err)
}
report, err := engine.SmokeTest(ctx, concreteRequest)
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: report, Diagnostics: diagnostics}, err
messages = messages.withOperationError(err)
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: report, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
}
return suite
}

View File

@@ -184,14 +184,14 @@ func checkSmokeFormat(format string) error {
func runSmokeTest[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *smokeCommandInput[I]) commandkit.Outcome {
if err := promptKitContextError(invocation.Context); err != nil {
return definitionFailure(err, nil)
return definitionFailure("PromptKit smoke testing was canceled.", commandkit.Explanation{}, err)
}
var state S
if suite.NewState != nil {
state = suite.NewState()
}
if err := promptKitContextError(invocation.Context); err != nil {
return definitionFailure(err, nil)
return definitionFailure("PromptKit smoke testing was canceled.", commandkit.Explanation{}, err)
}
if input.kind == "all" {
return runSmokeAggregate(invocation, suite, input, state)
@@ -199,6 +199,7 @@ func runSmokeTest[I, S any](invocation commandkit.Invocation, suite Suite[I, S],
request := SmokeRequest{Selection: input.selection, Request: input.request}
acquired, operationErr := suite.SmokeTest(invocation.Context, request, input.selected, state)
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
if acquired.Value == nil && operationErr == nil {
operationErr = errors.New("PromptKit smoke test returned neither a report nor an error")
}
@@ -210,14 +211,21 @@ func runSmokeTest[I, S any](invocation commandkit.Invocation, suite Suite[I, S],
}
}
if acquired.Value == nil {
return definitionFailure(operationErr, acquired.Diagnostics)
return definitionFailure(smokeFailureMessage(input.kind), acquired.Explanation, operationErr)
}
output, encodeErr := encodeSmokeReport(acquired.Value)
if encodeErr != nil {
return smokeDeliveryFailure(operationErr, encodeErr, acquired.Diagnostics, false)
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
output, err := encodeSmokeReport(acquired.Value)
if err != nil {
return err
}
_, err = writer.Write(output)
return err
})
if operationErr != nil {
return definitionFailure(smokeFailureMessage(input.kind), acquired.Explanation, operationErr)
}
return writeSmokeReport(invocation, output, acquired.Diagnostics, operationErr)
return commandkit.Success()
}
func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *smokeCommandInput[I], state S) commandkit.Outcome {
@@ -228,13 +236,13 @@ func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I
promptkit.SmokeTestStructuredOutput,
}
results := make([]smokeResult, 0, len(kinds))
var diagnostics []commandkit.Diagnostic
var operationErrors []error
var explanations []commandkit.Explanation
for _, kind := range kinds {
if err := promptKitContextError(invocation.Context); err != nil {
if len(results) == 0 {
return definitionFailure(err, diagnostics)
return definitionFailure("PromptKit aggregate smoke testing was canceled.", commandkit.Explanation{}, err)
}
operationErrors = append(operationErrors, err)
break
@@ -253,10 +261,13 @@ func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I
}
}
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
results = append(results, smokeResult{Kind: kind, Report: acquired.Value, Err: operationErr})
diagnostics = append(diagnostics, acquired.Diagnostics...)
if operationErr != nil {
operationErrors = append(operationErrors, operationErr)
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) > 0 {
explanations = append(explanations, acquired.Explanation)
}
}
if err := promptKitContextError(invocation.Context); err != nil {
break
@@ -264,13 +275,57 @@ func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I
}
if len(results) == 0 {
return definitionFailure(errors.Join(operationErrors...), diagnostics)
return definitionFailure("PromptKit aggregate smoke testing did not produce any results.", combineSmokeExplanations(explanations), errors.Join(operationErrors...))
}
output, encodeErr := encodeSmokeResults(results)
if encodeErr != nil {
return smokeDeliveryFailure(errors.Join(operationErrors...), encodeErr, diagnostics, false)
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
output, err := encodeSmokeResults(results)
if err != nil {
return err
}
_, err = writer.Write(output)
return err
})
if len(operationErrors) > 0 {
return definitionFailure("One or more PromptKit smoke tests failed.", combineSmokeExplanations(explanations), errors.Join(operationErrors...))
}
return writeSmokeReport(invocation, output, diagnostics, errors.Join(operationErrors...))
return commandkit.Success()
}
func smokeFailureMessage(kind string) string {
return fmt.Sprintf("PromptKit %s smoke testing failed.", kind)
}
func combineSmokeExplanations(explanations []commandkit.Explanation) commandkit.Explanation {
var selected []commandkit.Explanation
for _, explanation := range explanations {
if explanation.Message != "" || len(explanation.Details) > 0 {
selected = append(selected, explanation)
}
}
if len(selected) == 0 {
return commandkit.Explanation{}
}
if len(selected) == 1 {
return selected[0]
}
combined := commandkit.Explanation{Message: selected[0].Message}
combined.Details = append(combined.Details, selected[0].Details...)
for _, explanation := range selected[1:] {
if explanation.Message != "" {
message := explanation.Message
combined.Details = append(combined.Details, func(writer io.Writer) error {
return writeSmokeExplanationMessage(writer, message)
})
}
combined.Details = append(combined.Details, explanation.Details...)
}
return combined
}
func writeSmokeExplanationMessage(writer io.Writer, message string) error {
_, err := io.WriteString(writer, message+"\n")
return err
}
func smokeRequestForKind(seed promptkit.SmokeTestRequest, kind promptkit.SmokeTestKind) promptkit.SmokeTestRequest {
@@ -312,30 +367,3 @@ func copyString(value *string) *string {
copy := *value
return &copy
}
func writeSmokeReport(invocation commandkit.Invocation, output []byte, diagnostics []commandkit.Diagnostic, operationErr error) commandkit.Outcome {
written, deliveryErr := invocation.Streams.Out.Write(output)
if deliveryErr == nil && written != len(output) {
deliveryErr = io.ErrShortWrite
}
if deliveryErr != nil {
return smokeDeliveryFailure(operationErr, deliveryErr, diagnostics, true)
}
if operationErr != nil {
return definitionFailure(operationErr, diagnostics)
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
}
func smokeDeliveryFailure(operationErr, deliveryErr error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome {
cause := operationErr
if cause == nil {
cause = deliveryErr
}
outcome := definitionFailure(cause, diagnostics)
outcome.DeliveryError = deliveryErr
outcome.ReportIncomplete = reportStarted
return outcome
}

View File

@@ -105,16 +105,36 @@ func TestSmokeTestCommandBindsRequestAndRendersFindings(t *testing.T) {
func TestSmokeTestCommandDeliversReportWithOperationError(t *testing.T) {
sentinel := errors.New("native smoke operation failed")
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent smoke warning")
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
SmokeTest: func(context.Context, SmokeRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: promptkit.SmokeTestAuthentication, Completed: true}}, sentinel
return commandkit.Acquired[*promptkit.SmokeTestReport]{
Value: &promptkit.SmokeTestReport{Kind: promptkit.SmokeTestAuthentication, Completed: true},
Diagnostics: []commandkit.Diagnostic{warning},
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
_, err := io.WriteString(writer, "Native authentication context\n")
return err
}}},
}, sentinel
},
}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: &output, Err: io.Discard})
var output, diagnosticOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, sentinel) || output.Len() == 0 {
t.Fatalf("report-plus-error outcome = (%#v, %q)", outcome, output.String())
}
warningAt := strings.Index(diagnosticOutput.String(), "independent smoke warning")
explanationAt := strings.Index(diagnosticOutput.String(), "Native authentication context")
if warningAt < 0 || explanationAt < 0 || warningAt >= explanationAt || strings.Contains(diagnosticOutput.String(), sentinel.Error()) {
t.Fatalf("smoke warning and native terminal context were not separate and ordered: %q", diagnosticOutput.String())
}
output.Reset()
diagnosticOutput.Reset()
outcome = app.Execute(context.Background(), []string{"promptkit", "test", "authentication", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, sentinel) || output.Len() != 0 || !strings.Contains(diagnosticOutput.String(), "Native authentication context") || strings.Contains(diagnosticOutput.String(), "independent smoke warning") {
t.Fatalf("quiet report-plus-error = (%#v, out=%q, err=%q)", outcome, output.String(), diagnosticOutput.String())
}
app = newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
SmokeTest: func(context.Context, SmokeRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
@@ -154,7 +174,7 @@ func TestSmokeTestCommandRetainsShortReportWrite(t *testing.T) {
},
}))
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: smokeShortWriter{}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, io.ErrShortWrite) || !errors.Is(outcome.DeliveryError, io.ErrShortWrite) || !outcome.ReportIncomplete {
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, io.ErrShortWrite) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("short report delivery = %#v", outcome)
}
}

View File

@@ -105,10 +105,9 @@ func TestSmokeTestEncodingAndWriterFailuresRetainOutcomeFields(t *testing.T) {
} {
t.Run(test.name, func(t *testing.T) {
var diagnosticOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: test.writer, Err: &diagnosticOutput})
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication", "--verbose"}, commandkit.Streams{Out: test.writer, Err: &diagnosticOutput})
warningIndex := strings.Index(diagnosticOutput.String(), "smoke warning")
operationIndex := strings.Index(diagnosticOutput.String(), operationErr.Error())
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, operationErr) || !errors.Is(outcome.DeliveryError, test.want) || !outcome.ReportIncomplete || outcome.DiagnosticDelivery != commandkit.DeliveryComplete || warningIndex < 0 || operationIndex < warningIndex {
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, operationErr) || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete || warningIndex < 0 || strings.Contains(diagnosticOutput.String(), operationErr.Error()) {
t.Fatalf("report delivery = %#v, diagnostics=%q", outcome, diagnosticOutput.String())
}
})
@@ -116,8 +115,8 @@ func TestSmokeTestEncodingAndWriterFailuresRetainOutcomeFields(t *testing.T) {
diagnosticErr := errors.New("smoke diagnostic writer failed")
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: &output, Err: partialReportWriter{err: diagnosticErr}})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, operationErr) || !errors.Is(outcome.DeliveryError, diagnosticErr) || outcome.ReportIncomplete || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || output.Len() == 0 {
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication", "--verbose"}, commandkit.Streams{Out: &output, Err: partialReportWriter{err: diagnosticErr}})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, operationErr) || !errors.Is(outcome.Presentation.Diagnostics.WriteError, diagnosticErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || output.Len() == 0 {
t.Fatalf("diagnostic delivery = (%#v, %q)", outcome, output.String())
}
@@ -130,7 +129,7 @@ func TestSmokeTestEncodingAndWriterFailuresRetainOutcomeFields(t *testing.T) {
}))
output.Reset()
outcome = encodeApp.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause == nil || !errors.Is(outcome.DeliveryError, outcome.Cause) || outcome.ReportIncomplete || output.Len() != 0 {
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || outcome.Presentation.Output.RenderError == nil || output.Len() != 0 {
t.Fatalf("encoding failure = (%#v, %q)", outcome, output.String())
}
}
@@ -151,15 +150,24 @@ func TestSmokeAggregatePreservesMultipleTypedCauses(t *testing.T) {
case promptkit.SmokeTestAuthentication:
err = secondErr
}
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind}}, err
acquired := commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind}}
if err != nil {
acquired.Explanation = commandkit.Explanation{Message: string(request.Request.Kind) + " native context"}
}
return acquired, err
},
}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
var output, diagnosticOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "all"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
var retained *smokeTypedError
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, firstErr) || !errors.Is(outcome.Cause, secondErr) || !errors.As(outcome.Cause, &retained) || retained != firstErr {
t.Fatalf("joined aggregate errors lost identity: %#v, retained=%#v", outcome, retained)
}
firstContext := strings.Index(diagnosticOutput.String(), "reachability native context")
secondContext := strings.Index(diagnosticOutput.String(), "authentication native context")
if firstContext < 0 || secondContext < 0 || firstContext >= secondContext {
t.Fatalf("aggregate native explanations were not retained in order: %q", diagnosticOutput.String())
}
var document struct {
Tests []struct {
Kind string `yaml:"kind"`

View File

@@ -213,7 +213,7 @@ func TestWithRequestValidationBuilderOutputReachesNativeValidation(t *testing.T)
}
}
func TestWithRequestValidationRetainsNilEngineAndLoadDiagnostics(t *testing.T) {
func TestWithRequestValidationRetainsNilEngineAndLoadExplanation(t *testing.T) {
request := ValidationRequest{Request: promptkit.RunRequest{PromptID: "empty", ProfileID: "default"}}
nilEngineSuite := WithRequestValidation(Suite[struct{}, struct{}]{}, func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
return nil, nil
@@ -232,8 +232,8 @@ func TestWithRequestValidationRetainsNilEngineAndLoadDiagnostics(t *testing.T) {
}, nil)
acquired, err = loadFailure.ValidateRequest(context.Background(), request, struct{}{}, struct{}{})
var retained *configkit.LoadError
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err)
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err)
}
}
@@ -289,7 +289,7 @@ func TestWithRequestValidationCancellationBoundaries(t *testing.T) {
}, nil)
acquired, err := suite.ValidateRequest(ctx, request, 0, 0)
var retained *configkit.LoadError
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 || loaderCalls != 1 {
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 || loaderCalls != 1 {
t.Fatalf("cancelled load = (%#v, %v), loader calls=%d", acquired, err, loaderCalls)
}
})

View File

@@ -2,6 +2,7 @@ package ckprompt
import (
"errors"
"fmt"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
promptkit "gitea.maximumdirect.net/eric/promptkit"
@@ -102,26 +103,25 @@ func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command {
func runRequestValidation[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *validationCommandInput[I]) commandkit.Outcome {
if err := promptKitContextError(invocation.Context); err != nil {
return definitionFailure(err, nil)
return definitionFailure("PromptKit request validation was canceled.", commandkit.Explanation{}, err)
}
var state S
if suite.NewState != nil {
state = suite.NewState()
}
if err := promptKitContextError(invocation.Context); err != nil {
return definitionFailure(err, nil)
return definitionFailure("PromptKit request validation was canceled.", commandkit.Explanation{}, err)
}
acquired, err := suite.ValidateRequest(invocation.Context, input.request, input.selected, state)
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
if err != nil {
return definitionFailure(err, acquired.Diagnostics)
return definitionFailure(fmt.Sprintf("Unable to validate the PromptKit request for prompt %q.", input.promptID), acquired.Explanation, err)
}
if err := promptKitContextError(invocation.Context); err != nil {
return definitionFailure(err, acquired.Diagnostics)
return definitionFailure(fmt.Sprintf("Validation of the PromptKit request for prompt %q was canceled.", input.promptID), acquired.Explanation, err)
}
output, err := encodePromptYAMLBytes(requestValidationReport{PromptID: input.promptID, Valid: true})
if err != nil {
return definitionFailure(err, acquired.Diagnostics)
}
return writePromptReport(invocation, output, acquired.Diagnostics)
return renderPreparedPromptReport(invocation, acquired.Explanation, func() ([]byte, error) {
return encodePromptYAMLBytes(requestValidationReport{PromptID: input.promptID, Valid: true})
})
}

View File

@@ -98,7 +98,7 @@ func TestValidationCommandIsAvailableIndependentlyAndOwnsItsFamily(t *testing.T)
}}))
var help bytes.Buffer
outcome := validationOnly.Execute(context.Background(), []string{"help", "promptkit", "requirements"}, commandkit.Streams{Out: &help, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(help.String(), "validate") || strings.Contains(help.String(), "show") {
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(help.String(), "validate") || strings.Contains(help.String(), "\n show ") {
t.Fatalf("validation-only availability = (%#v, %q)", outcome, help.String())
}
@@ -359,41 +359,32 @@ func TestValidationCommandUsesNativeOutcomesAndMinimalConfirmation(t *testing.T)
}
func TestValidationCommandRetainsProviderDiagnosticsAndClassifiesFailures(t *testing.T) {
warning := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "validation warning\n")
return err
}}
withWarning := func(diagnostics []commandkit.Diagnostic) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Severity == commandkit.SeverityWarning && diagnostic.Write != nil {
var output bytes.Buffer
if diagnostic.Write(&output) == nil && output.String() == "validation warning\n" {
return true
}
}
}
return false
}
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "validation warning")
var diagnostics bytes.Buffer
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
return commandkit.Acquired[struct{}]{Diagnostics: []commandkit.Diagnostic{warning}}, nil
}}))
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete || !withWarning(outcome.Diagnostics) || diagnostics.String() != "validation warning\n" {
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, commandkit.Streams{Out: io.Discard, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete || !strings.Contains(diagnostics.String(), "validation warning") {
t.Fatalf("warning-only success = %#v, stderr=%q", outcome, diagnostics.String())
}
var quietReport, quietDiagnostics bytes.Buffer
quietOutcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--quiet"}, commandkit.Streams{Out: &quietReport, Err: &quietDiagnostics})
if quietOutcome.Kind != commandkit.OutcomeSuccess || quietOutcome.Presentation.Output.State != commandkit.DeliverySuppressed || quietOutcome.Presentation.Diagnostics.State != commandkit.DeliverySuppressed || quietReport.Len() != 0 || quietDiagnostics.Len() != 0 {
t.Fatalf("quiet validation = %#v, report=%q diagnostics=%q", quietOutcome, quietReport.String(), quietDiagnostics.String())
}
providerErr := errors.New("validation provider failed")
failedApp := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
return commandkit.Acquired[struct{}]{Diagnostics: []commandkit.Diagnostic{warning}}, providerErr
}}))
diagnostics.Reset()
outcome = failedApp.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, providerErr) || !withWarning(outcome.Diagnostics) || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
outcome = failedApp.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, commandkit.Streams{Out: io.Discard, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, providerErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
t.Fatalf("provider failure = %#v, stderr=%q", outcome, diagnostics.String())
}
if strings.Count(diagnostics.String(), "validation warning") != 1 || strings.Count(diagnostics.String(), providerErr.Error()) != 1 {
t.Fatalf("provider diagnostics were lost or duplicated: stderr=%q", diagnostics.String())
if strings.Count(diagnostics.String(), "validation warning") != 1 || strings.Contains(diagnostics.String(), providerErr.Error()) || !strings.Contains(diagnostics.String(), "Unable to validate the PromptKit request") {
t.Fatalf("provider warning or terminal explanation was incorrect: stderr=%q", diagnostics.String())
}
builderErr := errors.New("request builder failed")
@@ -418,16 +409,13 @@ func TestValidationCommandRetainsProviderDiagnosticsAndClassifiesFailures(t *tes
}, nil)))
outcome = loadApp.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
var retained *configkit.LoadError
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, loadErr) || !errors.As(outcome.Cause, &retained) || retained != loadErr || len(outcome.Diagnostics) == 0 {
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, loadErr) || !errors.As(outcome.Cause, &retained) || retained != loadErr || outcome.Explanation.Message == "" && len(outcome.Explanation.Details) == 0 {
t.Fatalf("loader failure = %#v, want retained ConfigKit error and diagnostics", outcome)
}
}
func TestValidationCommandCancellationPreventsAcquisitionAndReport(t *testing.T) {
warning := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "validation warning\n")
return err
}}
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "validation warning")
calls := 0
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
calls++
@@ -448,8 +436,9 @@ func TestValidationCommandCancellationPreventsAcquisitionAndReport(t *testing.T)
return commandkit.Acquired[struct{}]{Diagnostics: []commandkit.Diagnostic{warning}}, nil
}}))
output.Reset()
outcome = canceledApp.Execute(ctx, []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !withValidationWarning(outcome.Diagnostics) || output.Len() != 0 {
var diagnostics bytes.Buffer
outcome = canceledApp.Execute(ctx, []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !strings.Contains(diagnostics.String(), "validation warning") || output.Len() != 0 {
t.Fatalf("post-acquisition cancellation = %#v, output=%q", outcome, output.String())
}
@@ -459,8 +448,9 @@ func TestValidationCommandCancellationPreventsAcquisitionAndReport(t *testing.T)
cancel()
return commandkit.Acquired[struct{}]{Diagnostics: []commandkit.Diagnostic{warning}}, providerErr
}}))
outcome = errorApp.Execute(ctx, []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, providerErr) || !withValidationWarning(outcome.Diagnostics) {
var errorDiagnostics bytes.Buffer
outcome = errorApp.Execute(ctx, []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, commandkit.Streams{Out: io.Discard, Err: &errorDiagnostics})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, providerErr) || !strings.Contains(errorDiagnostics.String(), "validation warning") {
t.Fatalf("provider error after cancellation = %#v", outcome)
}
}
@@ -484,28 +474,16 @@ func TestValidationCommandPreservesReportAndDiagnosticDeliveryFailures(t *testin
} {
t.Run(test.name, func(t *testing.T) {
var diagnosticOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: test.writer, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, test.want) || !errors.Is(outcome.DeliveryError, test.want) || outcome.DiagnosticDelivery != commandkit.DeliveryComplete || !strings.Contains(diagnosticOutput.String(), "validation warning") {
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, commandkit.Streams{Out: test.writer, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete || !strings.Contains(diagnosticOutput.String(), "validation warning") {
t.Fatalf("report delivery = %#v, diagnostics=%q", outcome, diagnosticOutput.String())
}
})
}
diagnosticErr := errors.New("validation diagnostic writer failed")
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: partialReportWriter{err: diagnosticErr}})
if outcome.Kind != commandkit.OutcomeOperational || outcome.ReportIncomplete || !errors.Is(outcome.DeliveryError, diagnosticErr) || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete {
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, commandkit.Streams{Out: io.Discard, Err: partialReportWriter{err: diagnosticErr}})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Diagnostics.WriteError, diagnosticErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
t.Fatalf("diagnostic delivery = %#v", outcome)
}
}
func withValidationWarning(diagnostics []commandkit.Diagnostic) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Severity == commandkit.SeverityWarning && diagnostic.Write != nil {
var output bytes.Buffer
if diagnostic.Write(&output) == nil && output.String() == "validation warning\n" {
return true
}
}
}
return false
}

148
internal/alias/machine.go Normal file
View File

@@ -0,0 +1,148 @@
package alias
import (
"sort"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
type symbolKind uint8
const (
literalSymbol symbolKind = iota
otherSymbol
endSymbol
boundarySymbol
)
type symbol struct {
kind symbolKind
value string
}
type transitionKey struct {
control int
read symbol
}
type transition struct {
next int
push []symbol
rule int
}
// Machine is a finite-control pushdown rewrite model compiled from a resolver.
// Its transitions replace the stack's first symbol with zero to two symbols.
type Machine struct {
start int
halt int
transitions map[transitionKey]transition
alphabet []symbol
literals map[string]struct{}
mappings []Mapping
}
// Compile builds a finite stack machine with explicit lookup, restoration,
// and rewrite transitions for the mappings.
func Compile(mappings []Mapping) (*Resolver, *Machine, error) {
resolver, err := NewResolver(mappings)
if err != nil {
return nil, nil, err
}
return resolver, compileMachine(resolver), nil
}
func compileMachine(resolver *Resolver) *Machine {
// Trie controls only move to child nodes. Helper controls prepend a fixed
// number of saved symbols and then halt or return to the trie root after a
// selected mapping has been completed.
// End and boundary symbols are only restored as the final item below any
// emitted command tokens. No transition places a marker above ordinary
// input, so each remains the unique bottom marker of a valid stack.
machine := &Machine{
start: 0,
halt: len(resolver.trie),
transitions: make(map[transitionKey]transition),
literals: make(map[string]struct{}),
mappings: resolver.mappings,
}
for _, mapping := range resolver.mappings {
for _, path := range []commandpath.Path{mapping.Source, mapping.Target} {
for _, component := range path {
machine.literals[component] = struct{}{}
}
}
}
for literal := range machine.literals {
machine.alphabet = append(machine.alphabet, symbol{kind: literalSymbol, value: literal})
}
sort.Slice(machine.alphabet, func(i, j int) bool { return machine.alphabet[i].value < machine.alphabet[j].value })
machine.alphabet = append(machine.alphabet, symbol{kind: otherSymbol}, symbol{kind: endSymbol}, symbol{kind: boundarySymbol})
nextControl := machine.halt + 1
for nodeIndex, node := range resolver.trie {
for _, input := range machine.alphabet {
if input.kind == literalSymbol {
if childIndex, exists := node.children[input.value]; exists {
machine.transitions[transitionKey{control: nodeIndex, read: input}] = transition{next: childIndex, rule: -1}
continue
}
}
word, next, rule := machine.lookupOutcome(resolver, nodeIndex, input)
machine.addWordTransition(nodeIndex, input, word, next, rule, &nextControl)
}
}
return machine
}
func (machine *Machine) lookupOutcome(resolver *Resolver, nodeIndex int, input symbol) ([]symbol, int, int) {
node := resolver.trie[nodeIndex]
word := make([]symbol, 0, len(node.path)+1)
next := machine.halt
rule := -1
if node.best >= 0 {
mapping := resolver.mappings[node.best]
matchedLength := len(mapping.Source)
for _, component := range mapping.Target {
word = append(word, symbol{kind: literalSymbol, value: component})
}
for _, component := range node.path[matchedLength:] {
word = append(word, symbol{kind: literalSymbol, value: component})
}
next = machine.start
rule = node.best
} else {
for _, component := range node.path {
word = append(word, symbol{kind: literalSymbol, value: component})
}
}
word = append(word, input)
return word, next, rule
}
func (machine *Machine) addWordTransition(control int, read symbol, word []symbol, next, rule int, nextControl *int) {
key := transitionKey{control: control, read: read}
if len(word) <= 2 {
machine.transitions[key] = transition{next: next, push: append([]symbol(nil), word...), rule: rule}
return
}
helper := *nextControl
*nextControl = *nextControl + 1
machine.transitions[key] = transition{next: helper, push: append([]symbol(nil), word[len(word)-2:]...), rule: rule}
for index := len(word) - 3; index >= 0; index-- {
following := next
if index > 0 {
following = *nextControl
*nextControl = *nextControl + 1
}
expectedTop := word[index+1]
machine.transitions[transitionKey{control: helper, read: expectedTop}] = transition{
next: following,
push: []symbol{word[index], expectedTop},
rule: rule,
}
helper = following
}
}

View File

@@ -0,0 +1,28 @@
package alias
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
func TestResolveWithProvenanceCarriesInputOriginsThroughChains(t *testing.T) {
resolver, err := NewResolver([]Mapping{
{Source: commandpath.Path{"collections"}, Target: commandpath.Path{"list", "collections"}},
{Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"promptkit", "list", "collections"}},
})
if err != nil {
t.Fatal(err)
}
path := commandpath.Path{"collections", "--format", "yaml"}
gotPath, gotOrigins := resolver.ResolveWithProvenance(path)
if want := (commandpath.Path{"promptkit", "list", "collections", "--format", "yaml"}); !reflect.DeepEqual(gotPath, want) {
t.Fatalf("resolved path = %v, want %v", gotPath, want)
}
wantOrigins := [][]int{{0}, {0}, {0}, {1}, {2}}
if !reflect.DeepEqual(gotOrigins, wantOrigins) {
t.Fatalf("origins = %v, want %v", gotOrigins, wantOrigins)
}
}

184
internal/alias/resolver.go Normal file
View File

@@ -0,0 +1,184 @@
// Package alias models application command-path rewriting independently of
// command execution and the parsing backend.
package alias
import (
"fmt"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
// Mapping replaces Source with Target when Source is the longest matching
// prefix of an entered command path.
type Mapping struct {
Source commandpath.Path
Target commandpath.Path
}
type trieNode struct {
children map[string]int
mapping int
best int
parent int
path commandpath.Path
}
// Resolver selects longest matching command-path mappings.
type Resolver struct {
mappings []Mapping
trie []trieNode
}
// NewResolver builds a resolver from normalized command-path mappings.
func NewResolver(mappings []Mapping) (*Resolver, error) {
resolver := &Resolver{trie: []trieNode{{mapping: -1, best: -1, parent: -1}}}
for _, mapping := range mappings {
if len(mapping.Source) == 0 {
return nil, fmt.Errorf("command alias source path must not be empty")
}
if len(mapping.Target) == 0 {
return nil, fmt.Errorf("command alias target path must not be empty")
}
for _, component := range append(mapping.Source.Clone(), mapping.Target...) {
if err := commandpath.ValidateComponent(component); err != nil {
return nil, err
}
}
normalized := Mapping{Source: mapping.Source.Clone(), Target: mapping.Target.Clone()}
index := len(resolver.mappings)
resolver.mappings = append(resolver.mappings, normalized)
nodeIndex := 0
for _, component := range normalized.Source {
node := &resolver.trie[nodeIndex]
if node.children == nil {
node.children = make(map[string]int)
}
childIndex, exists := node.children[component]
if !exists {
childIndex = len(resolver.trie)
node.children[component] = childIndex
childPath := append(resolver.trie[nodeIndex].path.Clone(), component)
resolver.trie = append(resolver.trie, trieNode{mapping: -1, best: -1, parent: nodeIndex, path: childPath})
}
nodeIndex = childIndex
}
node := &resolver.trie[nodeIndex]
if node.mapping >= 0 {
previous := resolver.mappings[node.mapping]
if previous.Target.Equal(normalized.Target) {
resolver.mappings = resolver.mappings[:index]
continue
}
return nil, fmt.Errorf("conflicting command aliases for %q", normalized.Source.String())
}
node.mapping = index
node.path = normalized.Source.Clone()
}
resolver.setFallbacks()
return resolver, nil
}
func (resolver *Resolver) setFallbacks() {
for index := 0; index < len(resolver.trie); index++ {
node := &resolver.trie[index]
if index > 0 {
node.best = resolver.trie[node.parent].best
}
if node.mapping >= 0 {
node.best = node.mapping
}
}
}
// ResolveOnce applies at most one longest-prefix mapping. It returns the
// original input unchanged when no mapping matches.
func (resolver *Resolver) ResolveOnce(path commandpath.Path) (commandpath.Path, Mapping, bool) {
if resolver == nil {
return path.Clone(), Mapping{}, false
}
nodeIndex := 0
matched := -1
matchedLength := 0
for index, component := range path {
childIndex, exists := resolver.trie[nodeIndex].children[component]
if !exists {
break
}
nodeIndex = childIndex
if mapping := resolver.trie[nodeIndex].mapping; mapping >= 0 {
matched = mapping
matchedLength = index + 1
}
}
if matched < 0 {
return path.Clone(), Mapping{}, false
}
mapping := resolver.mappings[matched]
resolved := make(commandpath.Path, 0, len(mapping.Target)+len(path)-matchedLength)
resolved = append(resolved, mapping.Target...)
resolved = append(resolved, path[matchedLength:]...)
return resolved, Mapping{Source: mapping.Source.Clone(), Target: mapping.Target.Clone()}, true
}
// Resolve applies mappings until no source matches. Installation validates
// termination before a resolver is used for application input.
func (resolver *Resolver) Resolve(path commandpath.Path) commandpath.Path {
current := path.Clone()
for {
next, _, matched := resolver.ResolveOnce(current)
if !matched {
return current
}
current = next
}
}
// ResolutionPaths returns the entered path and each path produced by a
// longest-prefix rewrite. It supports metadata discovery along alias chains.
// Installation validates termination before this method is used.
func (resolver *Resolver) ResolutionPaths(path commandpath.Path) []commandpath.Path {
paths := []commandpath.Path{path.Clone()}
for {
next, _, matched := resolver.ResolveOnce(paths[len(paths)-1])
if !matched {
return paths
}
paths = append(paths, next)
}
}
// ResolveWithProvenance resolves a command path and carries the input token
// indexes that contributed to each output component. Rewritten target
// components inherit the origins of the matched source prefix.
func (resolver *Resolver) ResolveWithProvenance(path commandpath.Path) (commandpath.Path, [][]int) {
current := path.Clone()
origins := make([][]int, len(path))
for index := range path {
origins[index] = []int{index}
}
for {
next, mapping, matched := resolver.ResolveOnce(current)
if !matched {
return current, origins
}
matchedOrigins := make([]int, 0)
seen := make(map[int]struct{})
for _, componentOrigins := range origins[:len(mapping.Source)] {
for _, origin := range componentOrigins {
if _, exists := seen[origin]; exists {
continue
}
seen[origin] = struct{}{}
matchedOrigins = append(matchedOrigins, origin)
}
}
nextOrigins := make([][]int, 0, len(mapping.Target)+len(origins)-len(mapping.Source))
for range mapping.Target {
nextOrigins = append(nextOrigins, append([]int(nil), matchedOrigins...))
}
nextOrigins = append(nextOrigins, origins[len(mapping.Source):]...)
current = next
origins = nextOrigins
}
}

View File

@@ -0,0 +1,316 @@
package alias
import (
"errors"
"reflect"
"strconv"
"strings"
"testing"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
func TestResolverUsesLongestTokenPrefixAndPreservesSuffix(t *testing.T) {
resolver, err := NewResolver([]Mapping{
{Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}},
{Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"app", "collections"}},
})
if err != nil {
t.Fatal(err)
}
got := resolver.Resolve(commandpath.Path{"list", "collections", "extra"})
want := commandpath.Path{"app", "collections", "extra"}
if !got.Equal(want) {
t.Fatalf("Resolve() = %#v, want %#v", got, want)
}
got = resolver.Resolve(commandpath.Path{"list", "prompts"})
want = commandpath.Path{"promptkit", "list", "prompts"}
if !got.Equal(want) {
t.Fatalf("Resolve() = %#v, want %#v", got, want)
}
reversed, err := NewResolver([]Mapping{
{Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"app", "collections"}},
{Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}},
})
if err != nil {
t.Fatal(err)
}
if got := reversed.Resolve(commandpath.Path{"list", "collections", "extra"}); !got.Equal(commandpath.Path{"app", "collections", "extra"}) {
t.Fatalf("reverse-order Resolve() = %#v", got)
}
}
func TestResolverFallsBackAfterLongerTokenMatchFails(t *testing.T) {
resolver, err := NewResolver([]Mapping{
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"short"}},
{Source: commandpath.Path{"a", "b", "c"}, Target: commandpath.Path{"long"}},
})
if err != nil {
t.Fatal(err)
}
got := resolver.Resolve(commandpath.Path{"a", "b", "x"})
want := commandpath.Path{"short", "b", "x"}
if !got.Equal(want) {
t.Fatalf("Resolve() = %#v, want %#v", got, want)
}
}
func TestResolverDoesNotMatchUnknownTokens(t *testing.T) {
resolver, err := NewResolver([]Mapping{{Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}}})
if err != nil {
t.Fatal(err)
}
input := commandpath.Path{"listing", "collections"}
got := resolver.Resolve(input)
if !got.Equal(input) {
t.Fatalf("Resolve() = %#v, want unchanged %#v", got, input)
}
}
func TestCompiledMachineMatchesDirectResolver(t *testing.T) {
mappings := []Mapping{
{Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}},
{Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"promptkit", "list", "collections"}},
{Source: commandpath.Path{"pk"}, Target: commandpath.Path{"promptkit"}},
{Source: commandpath.Path{"promptkit", "list", "bundles"}, Target: commandpath.Path{"promptkit", "list", "collections"}},
{Source: commandpath.Path{"short"}, Target: commandpath.Path{"promptkit", "list"}},
{Source: commandpath.Path{"short", "b", "c"}, Target: commandpath.Path{"long"}},
}
resolver, machine, err := Compile(mappings)
if err != nil {
t.Fatal(err)
}
for key, operation := range machine.transitions {
if len(operation.push) > 2 {
t.Fatalf("transition %+v pushes %d symbols", key, len(operation.push))
}
}
cases := []commandpath.Path{
{"list"},
{"list", "prompts"},
{"list", "collections"},
{"list", "collections", "extra"},
{"pk", "list", "bundles", "tail"},
{"short", "b", "x"},
{"unknown", "operand"},
{},
}
for _, marker := range []symbolKind{endSymbol, boundarySymbol} {
for _, input := range cases {
t.Run(input.String(), func(t *testing.T) {
got, err := runMachineForTest(machine, input, marker)
if err != nil {
t.Fatal(err)
}
want := expectedSymbols(machine, resolver.Resolve(input), marker)
if !reflect.DeepEqual(got, want) {
t.Fatalf("machine result = %#v, want direct result %#v", got, want)
}
})
}
}
}
func TestResolverTreatsIdenticalMappingsAsIdempotent(t *testing.T) {
mapping := Mapping{Source: commandpath.Path{"list"}, Target: commandpath.Path{"promptkit", "list"}}
resolver, err := NewResolver([]Mapping{mapping, mapping})
if err != nil {
t.Fatal(err)
}
got := resolver.Resolve(commandpath.Path{"list", "prompts"})
if want := (commandpath.Path{"promptkit", "list", "prompts"}); !got.Equal(want) {
t.Fatalf("Resolve() = %#v, want %#v", got, want)
}
}
func TestResolverRejectsInvalidAndConflictingMappings(t *testing.T) {
for name, mappings := range map[string][]Mapping{
"empty source": {{Target: commandpath.Path{"target"}}},
"empty target": {{Source: commandpath.Path{"source"}}},
"conflicting target": {
{Source: commandpath.Path{"source"}, Target: commandpath.Path{"first"}},
{Source: commandpath.Path{"source"}, Target: commandpath.Path{"second"}},
},
} {
t.Run(name, func(t *testing.T) {
if _, err := NewResolver(mappings); err == nil {
t.Fatal("NewResolver() succeeded")
}
})
}
}
func TestTerminationRejectsCyclesAndGrowingPaths(t *testing.T) {
for name, mappings := range map[string][]Mapping{
"direct cycle": {
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"b"}},
{Source: commandpath.Path{"b"}, Target: commandpath.Path{"a"}},
},
"growing path": {
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"b", "extra"}},
{Source: commandpath.Path{"b"}, Target: commandpath.Path{"a"}},
},
"suffix-dependent cycle": {
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"b"}},
{Source: commandpath.Path{"b"}, Target: commandpath.Path{"group"}},
{Source: commandpath.Path{"b", "x"}, Target: commandpath.Path{"a", "x"}},
},
} {
t.Run(name, func(t *testing.T) {
err := ValidateTermination(mappings)
var loop *NonTerminationError
if !errors.As(err, &loop) {
t.Fatalf("ValidateTermination() error = %v, want NonTerminationError", err)
}
if len(loop.Mappings) == 0 {
t.Fatal("NonTerminationError has no participating mappings")
}
for _, mapping := range loop.Mappings {
if !strings.Contains(err.Error(), mapping.Source.String()) {
t.Fatalf("error %q omits participating source %q", err, mapping.Source.String())
}
}
if name == "suffix-dependent cycle" {
resolver, err := NewResolver(mappings)
if err != nil {
t.Fatal(err)
}
if got := resolver.Resolve(commandpath.Path{"a"}); !got.Equal(commandpath.Path{"group"}) {
t.Fatalf("bare route resolves to %#v, want [group]", got)
}
first, _, matched := resolver.ResolveOnce(commandpath.Path{"a", "x"})
second, _, matchedAgain := resolver.ResolveOnce(first)
if !matched || !matchedAgain || !second.Equal(commandpath.Path{"a", "x"}) {
t.Fatalf("suffix cycle is not demonstrated: first %#v, second %#v", first, second)
}
}
})
}
}
func TestTerminationAcceptsConsumingRepetition(t *testing.T) {
mappings := []Mapping{
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"group"}},
{Source: commandpath.Path{"group", "again"}, Target: commandpath.Path{"a"}},
}
if err := ValidateTermination(mappings); err != nil {
t.Fatalf("ValidateTermination() = %v", err)
}
resolver, err := NewResolver(mappings)
if err != nil {
t.Fatal(err)
}
got := resolver.Resolve(commandpath.Path{"a", "again", "again"})
if want := (commandpath.Path{"group"}); !got.Equal(want) {
t.Fatalf("Resolve() = %#v, want %#v", got, want)
}
}
func TestTerminationUsesLongestMatchBeforeFollowingShorterRule(t *testing.T) {
mappings := []Mapping{
{Source: commandpath.Path{"a"}, Target: commandpath.Path{"b", "x"}},
{Source: commandpath.Path{"b"}, Target: commandpath.Path{"a"}},
{Source: commandpath.Path{"b", "x"}, Target: commandpath.Path{"group"}},
}
if err := ValidateTermination(mappings); err != nil {
t.Fatalf("ValidateTermination() = %v", err)
}
resolver, err := NewResolver(mappings)
if err != nil {
t.Fatal(err)
}
if got := resolver.Resolve(commandpath.Path{"a"}); !got.Equal(commandpath.Path{"group"}) {
t.Fatalf("Resolve() = %#v, want [group]", got)
}
}
func TestTerminationAcceptsLongChainsInAnyRegistrationOrder(t *testing.T) {
const count = 80
mappings := make([]Mapping, 0, count)
for index := 0; index < count; index++ {
source := commandpath.Path{fmtAliasName(index)}
target := commandpath.Path{fmtAliasName(index + 1)}
if index == count-1 {
target = commandpath.Path{"canonical"}
}
mappings = append(mappings, Mapping{Source: source, Target: target})
}
if err := ValidateTermination(mappings); err != nil {
t.Fatalf("forward registration order: %v", err)
}
for left, right := 0, len(mappings)-1; left < right; left, right = left+1, right-1 {
mappings[left], mappings[right] = mappings[right], mappings[left]
}
if err := ValidateTermination(mappings); err != nil {
t.Fatalf("reverse registration order: %v", err)
}
}
func TestTerminalMarkersCannotBePopped(t *testing.T) {
_, machine, err := Compile([]Mapping{{Source: commandpath.Path{"a"}, Target: commandpath.Path{"b"}}})
if err != nil {
t.Fatal(err)
}
pop := saturatePopSummaries(machine)
for markerName, marker := range map[string]symbolKind{"end": endSymbol, "boundary": boundarySymbol} {
for from := range pop {
if from.symbol.kind == marker {
t.Fatalf("%s marker can be popped from control %d", markerName, from.control)
}
}
}
if err := ValidateTermination(nil); err != nil {
t.Fatalf("empty mapping set: %v", err)
}
}
func fmtAliasName(index int) string {
return "alias" + strconv.Itoa(index)
}
func expectedSymbols(machine *Machine, path commandpath.Path, marker symbolKind) []symbol {
result := make([]symbol, 0, len(path)+1)
for _, token := range path {
result = append(result, machine.classify(token))
}
result = append(result, symbol{kind: marker})
return result
}
func runMachineForTest(machine *Machine, input commandpath.Path, marker symbolKind) ([]symbol, error) {
stack := expectedSymbols(machine, input, marker)
control := machine.start
for steps := 0; steps < 10000; steps++ {
if control == machine.halt {
return stack, nil
}
if len(stack) == 0 {
return nil, errMachineStackEmpty
}
operation, ok := machine.transitions[transitionKey{control: control, read: stack[0]}]
if !ok {
return nil, errMachineTransitionMissing
}
stack = append(append([]symbol(nil), operation.push...), stack[1:]...)
control = operation.next
}
return nil, errMachineTestDidNotHalt
}
var (
errMachineStackEmpty = testError("compiled machine emptied its stack")
errMachineTransitionMissing = testError("compiled machine has no transition")
errMachineTestDidNotHalt = testError("compiled machine did not halt in the test interpreter")
)
type testError string
func (err testError) Error() string { return string(err) }
func (machine *Machine) classify(token string) symbol {
if _, exists := machine.literals[token]; exists {
return symbol{kind: literalSymbol, value: token}
}
return symbol{kind: otherSymbol}
}

View File

@@ -0,0 +1,262 @@
package alias
import (
"fmt"
"sort"
"strings"
)
type head struct {
control int
symbol symbol
}
type popKey struct {
from head
to int
}
type popSummary struct {
rules map[int]struct{}
}
type popWork struct {
key popKey
summary popSummary
}
type transitionUse struct {
key transitionKey
transition transition
}
type graphEdge struct {
to head
rules map[int]struct{}
}
// NonTerminationError identifies mappings that participate in an alias loop.
type NonTerminationError struct {
Mappings []Mapping
}
func (err *NonTerminationError) Error() string {
if err == nil || len(err.Mappings) == 0 {
return "command alias rewriting does not terminate"
}
parts := make([]string, 0, len(err.Mappings))
for _, mapping := range err.Mappings {
parts = append(parts, fmt.Sprintf("%q → %q", mapping.Source.String(), mapping.Target.String()))
}
return "command aliases do not terminate: " + strings.Join(parts, "; ")
}
// ValidateTermination rejects mapping sets whose deterministic prefix
// rewriting can repeat forever for some finite command path.
func ValidateTermination(mappings []Mapping) error {
_, machine, err := Compile(mappings)
if err != nil {
return err
}
if cycle := findHeadCycle(machine); cycle != nil {
return &NonTerminationError{Mappings: cycle}
}
return nil
}
func findHeadCycle(machine *Machine) []Mapping {
// Pop summaries exactly describe finite paths that remove one top symbol
// without inspecting below it. The head graph then represents nonempty
// paths that never pop below their starting level. Its cycle witnesses a
// repeatable computation even when the complete command path keeps growing.
pop := saturatePopSummaries(machine)
graph := buildHeadGraph(machine, pop)
colors := make(map[head]uint8, len(graph))
path := make([]head, 0)
pathEdges := make([]graphEdge, 0)
pathIndex := make(map[head]int)
var cycleRules map[int]struct{}
var visit func(head) bool
visit = func(current head) bool {
colors[current] = 1
pathIndex[current] = len(path)
path = append(path, current)
for _, edge := range graph[current] {
switch colors[edge.to] {
case 0:
pathEdges = append(pathEdges, edge)
if visit(edge.to) {
return true
}
pathEdges = pathEdges[:len(pathEdges)-1]
case 1:
start := pathIndex[edge.to]
cycleRules = make(map[int]struct{})
for _, cycleEdge := range pathEdges[start:] {
mergeRules(cycleRules, cycleEdge.rules)
}
mergeRules(cycleRules, edge.rules)
return true
}
}
path = path[:len(path)-1]
delete(pathIndex, current)
colors[current] = 2
return false
}
for current := range graph {
if colors[current] == 0 && visit(current) {
indices := make([]int, 0, len(cycleRules))
for index := range cycleRules {
if index >= 0 {
indices = append(indices, index)
}
}
sort.Ints(indices)
mappings := make([]Mapping, 0, len(indices))
for _, index := range indices {
if index < len(machine.mappings) {
mapping := machine.mappings[index]
mappings = append(mappings, Mapping{Source: mapping.Source.Clone(), Target: mapping.Target.Clone()})
}
}
return mappings
}
}
return nil
}
func saturatePopSummaries(machine *Machine) map[head]map[int]popSummary {
// Zero-symbol pushes seed the relation. Each newly found summary composes
// with transitions that push one symbol and with either side of transitions
// that push two symbols until no new finite pop path can be derived.
byHead := make(map[head]map[int]popSummary)
queue := make([]popWork, 0)
add := func(key popKey, rules map[int]struct{}) {
if byHead[key.from] == nil {
byHead[key.from] = make(map[int]popSummary)
}
if _, exists := byHead[key.from][key.to]; exists {
return
}
summary := popSummary{rules: cloneRules(rules)}
byHead[key.from][key.to] = summary
queue = append(queue, popWork{key: key, summary: summary})
}
ones := make(map[head][]transitionUse)
twosByPrefix := make(map[head][]transitionUse)
twosBySuffix := make(map[symbol][]transitionUse)
for key, operation := range machine.transitions {
switch len(operation.push) {
case 0:
rules := make(map[int]struct{})
addRule(rules, operation.rule)
add(popKey{from: head{control: key.control, symbol: key.read}, to: operation.next}, rules)
case 1:
ones[head{control: operation.next, symbol: operation.push[0]}] = append(ones[head{control: operation.next, symbol: operation.push[0]}], transitionUse{key: key, transition: operation})
case 2:
prefix := head{control: operation.next, symbol: operation.push[0]}
twosByPrefix[prefix] = append(twosByPrefix[prefix], transitionUse{key: key, transition: operation})
twosBySuffix[operation.push[1]] = append(twosBySuffix[operation.push[1]], transitionUse{key: key, transition: operation})
}
}
for cursor := 0; cursor < len(queue); cursor++ {
work := queue[cursor]
for _, use := range ones[work.key.from] {
add(popKey{
from: head{control: use.key.control, symbol: use.key.read},
to: work.key.to,
}, combineRules(use.transition.rule, work.summary.rules))
}
for _, use := range twosByPrefix[work.key.from] {
suffix := head{control: work.key.to, symbol: use.transition.push[1]}
finals := make([]int, 0, len(byHead[suffix]))
for final := range byHead[suffix] {
finals = append(finals, final)
}
sort.Ints(finals)
for _, final := range finals {
summary := byHead[suffix][final]
add(popKey{
from: head{control: use.key.control, symbol: use.key.read},
to: final,
}, combineRules(use.transition.rule, work.summary.rules, summary.rules))
}
}
for _, use := range twosBySuffix[work.key.from.symbol] {
if work.key.from.symbol != use.transition.push[1] {
continue
}
prefix := head{control: use.transition.next, symbol: use.transition.push[0]}
if first, exists := byHead[prefix][work.key.from.control]; exists {
add(popKey{
from: head{control: use.key.control, symbol: use.key.read},
to: work.key.to,
}, combineRules(use.transition.rule, first.rules, work.summary.rules))
}
}
}
return byHead
}
func buildHeadGraph(machine *Machine, pop map[head]map[int]popSummary) map[head][]graphEdge {
graph := make(map[head][]graphEdge)
for key, operation := range machine.transitions {
source := head{control: key.control, symbol: key.read}
rules := make(map[int]struct{})
addRule(rules, operation.rule)
switch len(operation.push) {
case 1:
graph[source] = append(graph[source], graphEdge{
to: head{control: operation.next, symbol: operation.push[0]},
rules: cloneRules(rules),
})
case 2:
graph[source] = append(graph[source], graphEdge{
to: head{control: operation.next, symbol: operation.push[0]},
rules: cloneRules(rules),
})
prefix := head{control: operation.next, symbol: operation.push[0]}
for nextControl, summary := range pop[prefix] {
graph[source] = append(graph[source], graphEdge{
to: head{control: nextControl, symbol: operation.push[1]},
rules: combineRules(operation.rule, summary.rules),
})
}
}
}
return graph
}
func addRule(rules map[int]struct{}, rule int) {
if rule >= 0 {
rules[rule] = struct{}{}
}
}
func cloneRules(rules map[int]struct{}) map[int]struct{} {
copy := make(map[int]struct{}, len(rules))
mergeRules(copy, rules)
return copy
}
func combineRules(rule int, groups ...map[int]struct{}) map[int]struct{} {
combined := make(map[int]struct{})
addRule(combined, rule)
for _, group := range groups {
mergeRules(combined, group)
}
return combined
}
func mergeRules(target, source map[int]struct{}) {
for rule := range source {
target[rule] = struct{}{}
}
}

136
internal/cobra/flags.go Normal file
View File

@@ -0,0 +1,136 @@
package cobra
import (
"fmt"
"strings"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
)
func interpretFlagTokens(argv []string, flags []commandmeta.Flag, aliases []commandmeta.FlagAlias) ([]string, map[string][]string, []commandmeta.FlagOccurrence, *commandmeta.Failure) {
canonical := make(map[string]commandmeta.Flag, len(flags)*2+2)
help := commandmeta.Flag{Name: "help", Short: "h", Boolean: true}
canonical["--help"] = help
canonical["-h"] = help
for _, flag := range flags {
canonical["--"+flag.Name] = flag
if flag.Short != "" {
canonical["-"+flag.Short] = flag
}
}
byAlias := make(map[string]commandmeta.Flag, len(aliases))
for _, alias := range aliases {
if target, ok := canonical[alias.Target]; ok {
byAlias[alias.Source] = target
}
}
spellings := make(map[string][]string)
var occurrences []commandmeta.FlagOccurrence
var interpreted []string
for index := 0; index < len(argv); {
token := argv[index]
if token == "--" {
interpreted = append(interpreted, argv[index:]...)
break
}
if strings.HasPrefix(token, "--") && len(token) > 2 {
body := token[2:]
equal := strings.IndexByte(body, '=')
inputName := body
if equal >= 0 {
inputName = body[:equal]
}
original := "--" + inputName
flag, ok := canonical[original]
if !ok {
flag, ok = byAlias[original]
}
if !ok {
interpreted = append(interpreted, token)
return interpreted, spellings, occurrences, nil
}
spellings[flag.Name] = append(spellings[flag.Name], original)
if equal >= 0 {
value := body[equal+1:]
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
index++
continue
}
if !flag.Boolean && index+1 < len(argv) {
value := argv[index+1]
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
index += 2
continue
}
if !flag.Boolean {
return interpreted, spellings, occurrences, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)}
}
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: "true"})
interpreted = append(interpreted, "--"+flag.Name)
index++
continue
}
if len(token) > 1 && token[0] == '-' {
if token == "-" {
interpreted = append(interpreted, token)
index++
continue
}
consumedNext := false
for offset := 1; offset < len(token); {
original := "-" + token[offset:offset+1]
flag, ok := canonical[original]
if !ok {
flag, ok = byAlias[original]
}
if !ok {
interpreted = append(interpreted, "-"+token[offset:])
return interpreted, spellings, occurrences, nil
}
spellings[flag.Name] = append(spellings[flag.Name], original)
suffix := token[offset+1:]
if flag.Boolean {
if strings.HasPrefix(suffix, "=") {
value := suffix[1:]
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
break
}
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: "true"})
interpreted = append(interpreted, "--"+flag.Name)
offset++
continue
}
if suffix != "" {
value := suffix
if strings.HasPrefix(value, "=") {
value = value[1:]
}
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
break
}
if index+1 < len(argv) {
value := argv[index+1]
interpreted = append(interpreted, "--"+flag.Name+"="+value)
occurrences = append(occurrences, commandmeta.FlagOccurrence{Name: flag.Name, Spelling: original, RawValue: value})
index += 2
consumedNext = true
break
}
return interpreted, spellings, occurrences, &commandmeta.Failure{Message: fmt.Sprintf("flag needs an argument: %s", original)}
}
if !consumedNext {
index++
}
continue
}
interpreted = append(interpreted, token)
index++
}
return interpreted, spellings, occurrences, nil
}

View File

@@ -0,0 +1,294 @@
package cobra
import (
"reflect"
"strings"
"testing"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
)
func flagGrammarTree() *commandmeta.Command {
return &commandmeta.Command{Name: "app", Children: []*commandmeta.Command{{
Name: "run", Runnable: true,
Flags: []commandmeta.Flag{
{Name: "color", Short: "c"},
{Name: "verbose", Short: "v", Boolean: true},
{Name: "quiet", Short: "q", Boolean: true},
{Name: "message", Short: "m"},
{Name: "tag", Short: "t", Repeated: true},
},
}}}
}
func flagGrammarOptions() ParseOptions {
return ParseOptions{FlagAliases: []commandmeta.FlagAlias{
{Source: "--colour", Target: "--color"},
{Source: "-C", Target: "--color"},
{Source: "-w", Target: "--verbose"},
{Source: "-z", Target: "--quiet"},
{Source: "--taggy", Target: "-t"},
{Source: "--hlep", Target: "--help"},
}}
}
func reportingFlagTree() *commandmeta.Command {
return &commandmeta.Command{Name: "app", Children: []*commandmeta.Command{{
Name: "run", Runnable: true, CoreFlags: commandmeta.ReportingFlags(),
Flags: []commandmeta.Flag{{Name: "message"}},
}}}
}
func reportingFlagOptions() ParseOptions {
return ParseOptions{FlagAliases: []commandmeta.FlagAlias{
{Source: "-q", Target: "--quiet"},
{Source: "-v", Target: "--verbose"},
{Source: "-d", Target: "--debug"},
}}
}
func TestParseReportsFlagOccurrencesInEnteredOrder(t *testing.T) {
result := Parse(reportingFlagTree(), []string{
"run", "--verbose=true", "-qd", "--progress=false", "--message=--quiet", "--", "--debug",
}, reportingFlagOptions())
if result.Failure != nil {
t.Fatalf("parse failed: %s", result.Failure.Message)
}
want := []commandmeta.FlagOccurrence{
{Name: "verbose", Spelling: "--verbose", RawValue: "true"},
{Name: "quiet", Spelling: "-q", RawValue: "true"},
{Name: "debug", Spelling: "-d", RawValue: "true"},
{Name: "progress", Spelling: "--progress", RawValue: "false"},
{Name: "message", Spelling: "--message", RawValue: "--quiet"},
}
if !reflect.DeepEqual(result.FlagOccurrences, want) {
t.Fatalf("flag occurrences = %#v, want %#v", result.FlagOccurrences, want)
}
if !reflect.DeepEqual(result.FlagValues, [][]string{{"--quiet"}}) || !reflect.DeepEqual(result.FlagSpellings, [][]string{{"--message"}}) {
t.Fatalf("consumer values or spellings changed: %#v, %#v", result.FlagValues, result.FlagSpellings)
}
}
func TestParseFlagOccurrencesRetainOnlyConsumedPrefixOnFailure(t *testing.T) {
result := Parse(reportingFlagTree(), []string{"run", "--verbose", "--unknown", "--quiet"}, reportingFlagOptions())
if result.Failure == nil || len(result.FlagOccurrences) != 1 || result.FlagOccurrences[0].Name != "verbose" {
t.Fatalf("unknown flag result = %#v", result)
}
result = Parse(reportingFlagTree(), []string{"run", "-qd", "--message"}, reportingFlagOptions())
if result.Failure == nil || len(result.FlagOccurrences) != 2 || result.FlagOccurrences[0].Name != "quiet" || result.FlagOccurrences[1].Name != "debug" {
t.Fatalf("missing value result = %#v", result)
}
result = Parse(reportingFlagTree(), []string{"missing", "--quiet"}, reportingFlagOptions())
if result.Failure == nil || len(result.FlagOccurrences) != 0 {
t.Fatalf("malformed path result = %#v", result)
}
}
func TestParseFlagOccurrenceKeepsOptionLookingValueAndConsumesTerminator(t *testing.T) {
result := Parse(reportingFlagTree(), []string{"run", "--message", "--quiet", "--", "--verbose"}, reportingFlagOptions())
if result.Failure != nil {
t.Fatalf("parse failed: %s", result.Failure.Message)
}
want := []commandmeta.FlagOccurrence{{Name: "message", Spelling: "--message", RawValue: "--quiet"}}
if !reflect.DeepEqual(result.FlagOccurrences, want) {
t.Fatalf("flag occurrences = %#v, want %#v", result.FlagOccurrences, want)
}
if !reflect.DeepEqual(result.Operands, []string{"--verbose"}) {
t.Fatalf("operands = %#v", result.Operands)
}
}
func TestParseCanonicalAndAliasShortGrammar(t *testing.T) {
for _, test := range []struct {
name string
args []string
values [][]string
spellings [][]string
}{
{"boolean cluster", []string{"run", "-vq"}, [][]string{nil, {"true"}, {"true"}, nil, nil}, [][]string{nil, {"-v"}, {"-q"}, nil, nil}},
{"aliased boolean cluster", []string{"run", "-wz"}, [][]string{nil, {"true"}, {"true"}, nil, nil}, [][]string{nil, {"-w"}, {"-z"}, nil, nil}},
{"value short separated", []string{"run", "-c", "blue"}, [][]string{{"blue"}, nil, nil, nil, nil}, [][]string{{"-c"}, nil, nil, nil, nil}},
{"value short equals", []string{"run", "-c=blue"}, [][]string{{"blue"}, nil, nil, nil, nil}, [][]string{{"-c"}, nil, nil, nil, nil}},
{"value short attached", []string{"run", "-cblue"}, [][]string{{"blue"}, nil, nil, nil, nil}, [][]string{{"-c"}, nil, nil, nil, nil}},
{"long alias", []string{"run", "--colour=blue"}, [][]string{{"blue"}, nil, nil, nil, nil}, [][]string{{"--colour"}, nil, nil, nil, nil}},
{"short alias", []string{"run", "-Cblue"}, [][]string{{"blue"}, nil, nil, nil, nil}, [][]string{{"-C"}, nil, nil, nil, nil}},
{"cluster value consumes suffix", []string{"run", "-vqcblue"}, [][]string{{"blue"}, {"true"}, {"true"}, nil, nil}, [][]string{{"-c"}, {"-v"}, {"-q"}, nil, nil}},
{"cluster value consumes next", []string{"run", "-vqc", "blue"}, [][]string{{"blue"}, {"true"}, {"true"}, nil, nil}, [][]string{{"-c"}, {"-v"}, {"-q"}, nil, nil}},
{"mixed canonical and alias cluster", []string{"run", "-wqcblue"}, [][]string{{"blue"}, {"true"}, {"true"}, nil, nil}, [][]string{{"-c"}, {"-w"}, {"-q"}, nil, nil}},
{"value takes remaining suffix", []string{"run", "-cvq"}, [][]string{{"vq"}, nil, nil, nil, nil}, [][]string{{"-c"}, nil, nil, nil, nil}},
{"boolean explicit false", []string{"run", "-v=false"}, [][]string{nil, {"false"}, nil, nil, nil}, [][]string{nil, {"-v"}, nil, nil, nil}},
{"boolean explicit false in cluster", []string{"run", "-qv=false"}, [][]string{nil, {"false"}, {"true"}, nil, nil}, [][]string{nil, {"-v"}, {"-q"}, nil, nil}},
{"empty canonical short", []string{"run", "-c="}, [][]string{{""}, nil, nil, nil, nil}, [][]string{{"-c"}, nil, nil, nil, nil}},
{"empty canonical long", []string{"run", "--color="}, [][]string{{""}, nil, nil, nil, nil}, [][]string{{"--color"}, nil, nil, nil, nil}},
{"empty separated short", []string{"run", "-c", ""}, [][]string{{""}, nil, nil, nil, nil}, [][]string{{"-c"}, nil, nil, nil, nil}},
{"empty aliased long", []string{"run", "--colour="}, [][]string{{""}, nil, nil, nil, nil}, [][]string{{"--colour"}, nil, nil, nil, nil}},
{"empty aliased short", []string{"run", "-C="}, [][]string{{""}, nil, nil, nil, nil}, [][]string{{"-C"}, nil, nil, nil, nil}},
{"empty boolean short", []string{"run", "-v="}, [][]string{nil, {""}, nil, nil, nil}, [][]string{nil, {"-v"}, nil, nil, nil}},
} {
t.Run(test.name, func(t *testing.T) {
result := Parse(flagGrammarTree(), test.args, flagGrammarOptions())
if result.Failure != nil {
t.Fatalf("parse failed: %s", result.Failure.Message)
}
if !reflect.DeepEqual(result.FlagValues, test.values) {
t.Errorf("values = %#v, want %#v", result.FlagValues, test.values)
}
if !reflect.DeepEqual(result.FlagSpellings, test.spellings) {
t.Errorf("spellings = %#v, want %#v", result.FlagSpellings, test.spellings)
}
})
}
}
func TestParseFlagAliasesPreserveValuesAndTerminators(t *testing.T) {
result := Parse(flagGrammarTree(), []string{"run", "--message", "--hlep", "--message=--hlep", "--taggy", "one", "--", "--taggy"}, flagGrammarOptions())
if result.Failure != nil {
t.Fatalf("parse failed: %s", result.Failure.Message)
}
if got, want := result.FlagValues[3], []string{"--hlep", "--hlep"}; !reflect.DeepEqual(got, want) {
t.Fatalf("message values = %#v, want %#v", got, want)
}
if got, want := result.FlagSpellings[3], []string{"--message", "--message"}; !reflect.DeepEqual(got, want) {
t.Fatalf("message spellings = %#v, want %#v", got, want)
}
if got, want := result.FlagValues[4], []string{"one"}; !reflect.DeepEqual(got, want) {
t.Fatalf("tag values = %#v, want %#v", got, want)
}
if got, want := result.Operands, []string{"--taggy"}; !reflect.DeepEqual(got, want) {
t.Fatalf("operands = %#v, want %#v", got, want)
}
}
func TestParseConsumesDoubleDashAsAFlagValue(t *testing.T) {
for _, spelling := range []string{"--color", "--colour", "-c", "-C"} {
t.Run(spelling, func(t *testing.T) {
result := Parse(flagGrammarTree(), []string{"run", spelling, "--", "--taggy=one", "--colour=red", "--", "--taggy=operand"}, flagGrammarOptions())
if result.Failure != nil {
t.Fatalf("parse failed: %s", result.Failure.Message)
}
if !reflect.DeepEqual(result.FlagValues[0], []string{"--", "red"}) || !reflect.DeepEqual(result.FlagValues[4], []string{"one"}) {
t.Errorf("flag values = %#v", result.FlagValues)
}
if !reflect.DeepEqual(result.FlagSpellings[0], []string{spelling, "--colour"}) || !reflect.DeepEqual(result.Operands, []string{"--taggy=operand"}) {
t.Errorf("spellings = %#v, operands = %#v", result.FlagSpellings, result.Operands)
}
})
}
for _, args := range [][]string{{"run", "-v", "--", "--colour"}, {"run", "--color", "blue", "--", "--colour"}} {
result := Parse(flagGrammarTree(), args, flagGrammarOptions())
if result.Failure != nil || !reflect.DeepEqual(result.Operands, []string{"--colour"}) {
t.Errorf("Parse(%q) = %#v", args, result)
}
}
}
func TestParseHelpAliasAndUnknownShortMembers(t *testing.T) {
result := Parse(flagGrammarTree(), []string{"run", "--hlep"}, flagGrammarOptions())
if result.Failure != nil || !result.HelpRequested {
t.Fatalf("help alias result = %#v", result)
}
result = Parse(flagGrammarTree(), []string{"run", "-vx"}, flagGrammarOptions())
if result.Failure == nil || result.Failure.Message == "" {
t.Fatalf("unknown short cluster member result = %#v", result)
}
result = Parse(flagGrammarTree(), []string{"run", "--color"}, flagGrammarOptions())
if result.Failure == nil || result.Failure.Message == "" {
t.Fatalf("missing value result = %#v", result)
}
result = Parse(flagGrammarTree(), []string{"run", "--colour"}, flagGrammarOptions())
if result.Failure == nil || result.Failure.Message == "" {
t.Fatalf("missing aliased value result = %#v", result)
}
result = Parse(flagGrammarTree(), []string{"run", "-c"}, flagGrammarOptions())
if result.Failure == nil || result.Failure.Message == "" {
t.Fatalf("missing short value result = %#v", result)
}
result = Parse(flagGrammarTree(), []string{"run", "-C"}, flagGrammarOptions())
if result.Failure == nil || result.Failure.Message == "" {
t.Fatalf("missing aliased short value result = %#v", result)
}
result = Parse(flagGrammarTree(), []string{"run", "-vfalse"}, flagGrammarOptions())
if result.Failure == nil || result.Failure.Message == "" {
t.Fatalf("boolean suffix without equals result = %#v", result)
}
}
func TestParseFlagAliasOccurrenceOrderAndSpellings(t *testing.T) {
result := Parse(flagGrammarTree(), []string{"run", "--taggy=first", "-t", "second", "--taggy", "third"}, flagGrammarOptions())
if result.Failure != nil {
t.Fatalf("parse failed: %s", result.Failure.Message)
}
if got, want := result.FlagValues[4], []string{"first", "second", "third"}; !reflect.DeepEqual(got, want) {
t.Fatalf("tag values = %#v, want %#v", got, want)
}
if got, want := result.FlagSpellings[4], []string{"--taggy", "-t", "--taggy"}; !reflect.DeepEqual(got, want) {
t.Fatalf("tag spellings = %#v, want %#v", got, want)
}
}
func TestParseFailuresRetainEnteredFlagSpellings(t *testing.T) {
for _, spelling := range []string{"--color", "--colour", "-c", "-C"} {
t.Run("missing/"+spelling, func(t *testing.T) {
result := Parse(flagGrammarTree(), []string{"run", spelling}, flagGrammarOptions())
if result.Failure == nil || !strings.Contains(result.Failure.Message, ": "+spelling) {
t.Fatalf("missing value failure = %#v, want entered spelling %q", result.Failure, spelling)
}
})
}
result := Parse(flagGrammarTree(), []string{"run", "-wC"}, flagGrammarOptions())
if result.Failure == nil || !strings.Contains(result.Failure.Message, ": -C") {
t.Fatalf("cluster missing value failure = %#v", result.Failure)
}
for _, test := range []struct {
args []string
spelling string
}{
{[]string{"--hlep=bad"}, "--hlep"},
{[]string{"-h=bad"}, "-h"},
{[]string{"--help=bad"}, "--help"},
{[]string{"--hlep=bad", "--help=true"}, "--hlep"},
{[]string{"-h=bad", "--hlep=true"}, "-h"},
{[]string{"--help=true", "--hlep=bad"}, "--hlep"},
} {
t.Run(strings.Join(test.args, " "), func(t *testing.T) {
result := Parse(flagGrammarTree(), append([]string{"run"}, test.args...), flagGrammarOptions())
if result.Failure == nil || !strings.Contains(result.Failure.Message, "for "+test.spelling) {
t.Fatalf("help failure = %#v, want entered spelling %q", result.Failure, test.spelling)
}
})
}
result = Parse(flagGrammarTree(), []string{"run", "--unknown", "--colour"}, flagGrammarOptions())
if result.Failure == nil || !strings.Contains(result.Failure.Message, "unknown") || strings.Contains(result.Failure.Message, "needs an argument") {
t.Fatalf("earlier parser failure = %#v", result.Failure)
}
}
func TestFlagInterpretationStopsAtTheBackendFailurePrefix(t *testing.T) {
for _, test := range []struct {
name string
args []string
want []string
}{
{"long", []string{"--debug", "--unknown"}, []string{"--debug", "--unknown"}},
{"short cluster", []string{"-dqz"}, []string{"--debug", "--quiet", "-z"}},
} {
t.Run(test.name, func(t *testing.T) {
// The later value must not be canonicalized or retained in spellings.
args := append(append([]string(nil), test.args...), "--message="+strings.Repeat("value", 1024), "--quiet=false")
flags := append(commandmeta.ReportingFlags(), commandmeta.Flag{Name: "message"})
interpreted, spellings, occurrences, failure := interpretFlagTokens(args, flags, reportingFlagOptions().FlagAliases)
if failure != nil || !reflect.DeepEqual(interpreted, test.want) || len(spellings["message"]) != 0 {
t.Fatalf("interpreted=%q spellings=%v failure=%v", interpreted, spellings, failure)
}
if len(occurrences) != len(test.want)-1 || occurrences[0].Name != "debug" {
t.Fatalf("consumed occurrences=%#v", occurrences)
}
result := Parse(reportingFlagTree(), append([]string{"run"}, args...), reportingFlagOptions())
if result.Failure == nil || !strings.Contains(result.Failure.Message, "unknown") || !reflect.DeepEqual(result.FlagOccurrences, occurrences) {
t.Fatalf("backend result=%#v", result)
}
})
}
}

View File

@@ -11,6 +11,7 @@ import (
"strings"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
spfcobra "github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -18,9 +19,11 @@ import (
// Session owns one mutable backend tree for a single command invocation.
type Session struct {
metadata *commandmeta.Command
root *spfcobra.Command
declarations map[*spfcobra.Command]*commandmeta.Command
metadata *commandmeta.Command
root *spfcobra.Command
declarations map[*spfcobra.Command]*commandmeta.Command
flagAliases []commandmeta.FlagAlias
flagAliasesForPath func([]string) []commandmeta.FlagAlias
}
// Prepare constructs a fresh backend tree for one invocation.
@@ -30,6 +33,8 @@ func Prepare(root *commandmeta.Command, options commandmeta.ParseOptions) *Sessi
return session
}
session.metadata = root
session.flagAliases = append([]commandmeta.FlagAlias(nil), options.FlagAliases...)
session.flagAliasesForPath = options.FlagAliasesForPath
options = normalizeOptions(options)
session.root, session.declarations = buildTree(root, options)
return session
@@ -63,31 +68,69 @@ func (session *Session) Parse(argv []string) commandmeta.Result {
}
result.Key = selectedNode.Key
var pathOK bool
result.Path, pathOK = pathFor(selected, session.declarations)
selectedPath, pathOK := pathFor(selected, session.declarations)
if !pathOK {
return failure(result, "selected command is not part of the declared tree")
}
result.Path = []string(selectedPath)
consumed := len(leading) - len(unconsumed)
if consumed != len(result.Path) || !equalPath(leading[:consumed], result.Path) {
if consumed != len(selectedPath) || !commandpath.FromTokens(leading[:consumed]).Equal(selectedPath) {
return failure(result, "command path must match a registered command exactly")
}
remaining := append(append([]string(nil), unconsumed...), tail...)
if err := selected.ParseFlags(remaining); err != nil {
remaining := append(commandpath.CloneTokens(unconsumed), tail...)
localFlags := make([]commandmeta.Flag, 0, len(selectedNode.Flags)+len(selectedNode.CoreFlags))
localFlags = append(localFlags, selectedNode.Flags...)
localFlags = append(localFlags, selectedNode.CoreFlags...)
aliases := session.flagAliases
if session.flagAliasesForPath != nil {
aliases = session.flagAliasesForPath(result.Path)
}
interpreted, spellings, occurrences, interpretationFailure := interpretFlagTokens(remaining, localFlags, aliases)
for _, flag := range localFlags {
if value := selected.Flags().Lookup(flag.Name); value != nil {
value.Value.(*rawValue).queueSpellings(spellings[flag.Name])
}
}
if helpFlag := selected.Flags().Lookup("help"); helpFlag != nil {
helpFlag.Value.(*rawValue).queueSpellings(spellings["help"])
}
if err := selected.ParseFlags(interpreted); err != nil {
result.FlagOccurrences = occurrences
return failure(result, err.Error())
}
// Parse the completed prefix first so an earlier backend failure keeps its
// precedence over a missing value discovered while interpreting aliases.
if interpretationFailure != nil {
result.FlagOccurrences = occurrences
result.Failure = interpretationFailure
return result
}
result.FlagOccurrences = occurrences
operands := selected.Flags().Args()
result.Operands = append([]string(nil), operands...)
result.FlagValues = make([][]string, len(selectedNode.Flags))
result.FlagSpellings = make([][]string, len(selectedNode.Flags))
for index, flag := range selectedNode.Flags {
if value := selected.Flags().Lookup(flag.Name); value != nil {
result.FlagValues[index] = value.Value.(*rawValue).valuesCopy()
raw := value.Value.(*rawValue)
result.FlagValues[index] = raw.valuesCopy()
result.FlagSpellings[index] = raw.spellingsCopy()
}
}
if helpFlag := selected.Flags().Lookup("help"); helpFlag != nil {
helpRequested, err := helpFlag.Value.(*rawValue).lastBool()
if err != nil {
for index, occurrence := range result.FlagOccurrences {
if occurrence.Name != "help" {
continue
}
if _, parseErr := strconv.ParseBool(occurrence.RawValue); parseErr != nil {
result.FlagOccurrences = result.FlagOccurrences[:index+1]
break
}
}
return failure(result, err.Error())
}
result.HelpRequested = helpRequested
@@ -105,12 +148,12 @@ func (session *Session) RenderHelp(path []string) (string, *commandmeta.Failure)
}
selected, remaining, err := session.root.Find(path)
if err != nil {
return "", &commandmeta.Failure{Message: fmt.Sprintf("unknown help path %q", strings.Join(path, " ")), Usage: true}
return "", &commandmeta.Failure{Message: fmt.Sprintf("unknown help path %q", commandpath.FromTokens(path).String()), Usage: true}
}
declaration := session.declarations[selected]
selectedPath, pathOK := pathFor(selected, session.declarations)
if declaration == nil || !pathOK || len(remaining) != 0 || !equalPath(path, selectedPath) {
return "", &commandmeta.Failure{Message: fmt.Sprintf("unknown help path %q", strings.Join(path, " ")), Usage: true}
if declaration == nil || !pathOK || len(remaining) != 0 || !commandpath.FromTokens(path).Equal(selectedPath) {
return "", &commandmeta.Failure{Message: fmt.Sprintf("unknown help path %q", commandpath.FromTokens(path).String()), Usage: true}
}
buffer := new(bytes.Buffer)
previousOut, previousErr := selected.OutOrStdout(), selected.ErrOrStderr()
@@ -126,6 +169,93 @@ func (session *Session) RenderHelp(path []string) (string, *commandmeta.Failure)
return buffer.String(), nil
}
// RenderHelpView renders canonical command metadata with the selected entered
// path and its application-owned aliases.
func (session *Session) RenderHelpView(view commandmeta.HelpView) (string, *commandmeta.Failure) {
if session == nil || session.root == nil || session.metadata == nil {
return "", &commandmeta.Failure{Message: "command tree is nil"}
}
if view.Discovery {
return renderDiscoveryHelp(session.metadata.Name, view), nil
}
content, failure := session.RenderHelp(view.CanonicalPath)
if failure != nil {
return "", failure
}
if !commandpath.FromTokens(view.EnteredPath).Equal(commandpath.FromTokens(view.CanonicalPath)) {
if len(view.CanonicalPath) > 0 {
entered := displayCommandPath(session.metadata.Name, view.EnteredPath)
canonical := displayCommandPath(session.metadata.Name, view.CanonicalPath)
lines := strings.Split(content, "\n")
adjusted := make([]string, 0, len(lines)+1)
annotated := false
for _, line := range lines {
if strings.HasPrefix(line, " "+canonical) {
line = strings.Replace(line, canonical, entered, 1)
if !annotated {
adjusted = append(adjusted, line, "Alias for: "+displayCommandPath(session.metadata.Name, view.CanonicalPath))
annotated = true
continue
}
}
adjusted = append(adjusted, line)
}
content = strings.Join(adjusted, "\n")
}
}
return appendHelpAliases(content, view.CommandAliases, view.FlagAliases), nil
}
func appendHelpAliases(content string, commandAliases []commandmeta.CommandAlias, flagAliases []commandmeta.FlagAlias) string {
var sections []string
if len(commandAliases) > 0 {
lines := []string{"Command Aliases:"}
for _, alias := range commandAliases {
lines = append(lines, " "+strings.Join(alias.Source, " ")+" → "+strings.Join(alias.Target, " "))
}
sections = append(sections, strings.Join(lines, "\n"))
}
if len(flagAliases) > 0 {
lines := []string{"Flag Aliases:"}
for _, alias := range flagAliases {
lines = append(lines, " "+alias.Source+" → "+alias.Target)
}
sections = append(sections, strings.Join(lines, "\n"))
}
if len(sections) == 0 {
return content
}
return strings.TrimRight(content, "\n") + "\n\n" + strings.Join(sections, "\n\n") + "\n"
}
func renderDiscoveryHelp(rootName string, view commandmeta.HelpView) string {
lines := []string{"Usage:", " " + displayCommandPath(rootName, view.EnteredPath) + " [command]"}
if len(view.CoreFlags) > 0 {
lines = append(lines, "", "Reporting Flags:")
for _, flag := range view.CoreFlags {
lines = append(lines, " --"+flag.Name+" — "+flag.Usage+" (default: "+flag.Default+")")
}
}
if len(view.CommandAliases) > 0 {
lines = append(lines, "", "Available Aliases:")
for _, alias := range view.CommandAliases {
lines = append(lines, " "+strings.Join(alias.Source, " ")+" → "+strings.Join(alias.Target, " "))
}
}
if len(view.FlagAliases) > 0 {
lines = append(lines, "", "Flag Aliases:")
for _, alias := range view.FlagAliases {
lines = append(lines, " "+alias.Source+" → "+alias.Target)
}
}
return strings.Join(lines, "\n") + "\n"
}
func displayCommandPath(rootName string, path []string) string {
parts := append([]string{rootName}, path...)
return strings.Join(parts, " ")
}
// UsageLine returns Cobra's usage line for an exact path on this session.
func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure) {
if session == nil || session.root == nil {
@@ -134,7 +264,7 @@ func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure)
selected, remaining, err := session.root.Find(path)
declaration := session.declarations[selected]
selectedPath, pathOK := pathFor(selected, session.declarations)
if err != nil || declaration == nil || !pathOK || len(remaining) != 0 || !equalPath(path, selectedPath) {
if err != nil || declaration == nil || !pathOK || len(remaining) != 0 || !commandpath.FromTokens(path).Equal(selectedPath) {
return "", &commandmeta.Failure{Message: "usage path is not registered"}
}
line := selected.UseLine()
@@ -206,7 +336,7 @@ func buildTree(root *commandmeta.Command, options commandmeta.ParseOptions) (*sp
helpFlag := backend.Flags().Lookup("help")
helpFlag.NoOptDefVal = "true"
helpFlag.Usage = "help for this command (default: false)"
for _, declarationFlag := range declaration.Flags {
for _, declarationFlag := range append(append([]commandmeta.Flag(nil), declaration.Flags...), declaration.CoreFlags...) {
value := &rawValue{boolean: declarationFlag.Boolean}
backend.Flags().VarP(value, declarationFlag.Name, declarationFlag.Short, flagUsage(declarationFlag))
backendFlag := backend.Flags().Lookup(declarationFlag.Name)
@@ -303,28 +433,19 @@ func flagUsage(flag commandmeta.Flag) string {
return usage
}
func equalPath(left, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func splitRoutingPrefix(argv []string) ([]string, []string) {
for index, argument := range argv {
if argument == "--" || (strings.HasPrefix(argument, "-") && argument != "-") {
return append([]string(nil), argv[:index]...), append([]string(nil), argv[index:]...)
// Empty strings and "-" can be operands but never command names.
// Keep them out of Find, which could skip them and select a child
// from a later operand instead of retaining the runnable parent.
if argument == "" || strings.HasPrefix(argument, "-") {
return commandpath.CloneTokens(argv[:index]), commandpath.CloneTokens(argv[index:])
}
}
return append([]string(nil), argv...), nil
return commandpath.CloneTokens(argv), nil
}
func pathFor(selected *spfcobra.Command, declarations map[*spfcobra.Command]*commandmeta.Command) ([]string, bool) {
func pathFor(selected *spfcobra.Command, declarations map[*spfcobra.Command]*commandmeta.Command) (commandpath.Path, bool) {
var reverse []string
for current := selected; current != nil; current = current.Parent() {
declaration := declarations[current]
@@ -338,7 +459,7 @@ func pathFor(selected *spfcobra.Command, declarations map[*spfcobra.Command]*com
for left, right := 0, len(reverse)-1; left < right; left, right = left+1, right-1 {
reverse[left], reverse[right] = reverse[right], reverse[left]
}
return reverse, true
return commandpath.FromTokens(reverse), true
}
func failure(result commandmeta.Result, message string) commandmeta.Result {
@@ -347,15 +468,25 @@ func failure(result commandmeta.Result, message string) commandmeta.Result {
}
type rawValue struct {
values []string
boolean bool
values []string
spellings []string
pendingSpellings []string
boolean bool
}
func (v *rawValue) Set(value string) error {
v.values = append(v.values, value)
if len(v.pendingSpellings) > 0 {
v.spellings = append(v.spellings, v.pendingSpellings[0])
v.pendingSpellings = v.pendingSpellings[1:]
}
return nil
}
func (v *rawValue) queueSpellings(spellings []string) {
v.pendingSpellings = append(v.pendingSpellings, spellings...)
}
func (v *rawValue) String() string {
if len(v.values) > 0 {
return v.values[len(v.values)-1]
@@ -374,12 +505,18 @@ func (v *rawValue) IsBoolFlag() bool { return v.boolean }
func (v *rawValue) valuesCopy() []string { return append([]string(nil), v.values...) }
func (v *rawValue) spellingsCopy() []string { return append([]string(nil), v.spellings...) }
func (v *rawValue) lastBool() (bool, error) {
var value bool
for _, raw := range v.values {
for index, raw := range v.values {
parsed, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("invalid boolean value %q for --help", raw)
spelling := "--help"
if index < len(v.spellings) {
spelling = v.spellings[index]
}
return false, fmt.Errorf("invalid boolean value %q for %s", raw, spelling)
}
value = parsed
}

View File

@@ -77,6 +77,62 @@ func TestParseInterspersedOperandsBooleanFlagsAndTerminator(t *testing.T) {
}
}
func TestReportingFlagsParseWithoutChangingConsumerFlagIndexes(t *testing.T) {
tree := &Command{Name: "app", CoreFlags: commandmeta.ReportingFlags(), Children: []*Command{
{Name: "config", CoreFlags: commandmeta.ReportingFlags(), Children: []*Command{
{Name: "show", Runnable: true, CoreFlags: commandmeta.ReportingFlags(), Flags: []Flag{{Name: "format"}}},
}},
}}
result := Parse(tree, []string{"config", "show", "--quiet", "-v", "--format=yaml"}, ParseOptions{
FlagAliases: []commandmeta.FlagAlias{{Source: "-v", Target: "--verbose"}},
})
if result.Failure != nil {
t.Fatalf("parse failed: %s", result.Failure.Message)
}
if !reflect.DeepEqual(result.FlagValues, [][]string{{"yaml"}}) {
t.Fatalf("consumer binding values = %#v", result.FlagValues)
}
if !reflect.DeepEqual(result.FlagSpellings, [][]string{{"--format"}}) {
t.Fatalf("consumer binding spellings = %#v", result.FlagSpellings)
}
}
func TestReportingFlagsAppearInCanonicalAndDiscoveryHelp(t *testing.T) {
flags := commandmeta.ReportingFlags()
root := &Command{Name: "app", CoreFlags: flags, Children: []*Command{
{Name: "group", CoreFlags: commandmeta.ReportingFlags(), Children: []*Command{
{Name: "run", Runnable: true, CoreFlags: commandmeta.ReportingFlags()},
}},
{Name: "version", Runnable: true, CoreFlags: commandmeta.ReportingFlags()},
}}
session := Prepare(root, ParseOptions{})
for _, path := range [][]string{nil, {"group"}, {"group", "run"}, {"version"}} {
help, failure := session.RenderHelp(path)
if failure != nil {
t.Fatalf("help for %v failed: %s", path, failure.Message)
}
for _, name := range []string{"quiet", "verbose", "debug", "progress"} {
if !strings.Contains(help, "--"+name) {
t.Errorf("help for %v omits --%s: %q", path, name, help)
}
}
}
discovery, failure := session.RenderHelpView(commandmeta.HelpView{
EnteredPath: []string{"shortcut"}, Discovery: true, CoreFlags: commandmeta.ReportingFlags(),
})
if failure != nil {
t.Fatal(failure.Message)
}
for _, name := range []string{"quiet", "verbose", "debug", "progress"} {
if !strings.Contains(discovery, "--"+name) {
t.Errorf("discovery help omits --%s: %q", name, discovery)
}
}
if strings.Contains(discovery, "Available Commands:") || len(session.Metadata().Children) != 2 {
t.Fatalf("discovery help manufactured canonical command metadata: %q", discovery)
}
}
func TestParseHelpIsLocalAndFalseDoesNotRequestHelp(t *testing.T) {
tree := &Command{Name: "app", Children: []*Command{
{Name: "run", Runnable: true, Flags: []Flag{{Name: "required", Required: true}}},

View File

@@ -18,8 +18,10 @@ type Command struct {
Description string
Runnable bool
Flags []Flag
Arguments []Argument
Children []*Command
// CoreFlags are parsed by CommandKit but do not occupy consumer binding indexes.
CoreFlags []Flag
Arguments []Argument
Children []*Command
}
// Argument describes one positional input for usage rendering.
@@ -41,26 +43,99 @@ type Flag struct {
Required bool
}
var reportingFlags = [...]Flag{
{Name: "quiet", Usage: "suppress substantive output and optional messages; keep failures visible", Default: "false", Boolean: true},
{Name: "verbose", Usage: "show warning and informational messages", Default: "false", Boolean: true},
{Name: "debug", Usage: "show debugging messages", Default: "false", Boolean: true},
{Name: "progress", Usage: "show progress messages", Default: "false", Boolean: true},
}
// ReportingFlags returns the flags shared by CommandKit's invocation reporter.
func ReportingFlags() []Flag { return append([]Flag(nil), reportingFlags[:]...) }
// IsReportingFlag reports whether name is reserved for a shared reporting flag.
func IsReportingFlag(name string) bool {
for _, flag := range reportingFlags {
if flag.Name == name {
return true
}
}
return false
}
// FlagAlias maps one option spelling to a canonical flag spelling.
type FlagAlias struct {
Source string
Target string
}
// CommandAlias describes one explicitly declared command route for help.
type CommandAlias struct {
Source []string
Target []string
}
// HelpView selects a canonical or alias-only command help presentation.
type HelpView struct {
EnteredPath []string
CanonicalPath []string
Discovery bool
CoreFlags []Flag
CommandAliases []CommandAlias
FlagAliases []FlagAlias
}
// Failure is a neutral parsing or presentation failure.
type Failure struct {
Message string
Usage bool
}
// FlagOccurrence records one parsed spelling and its unconverted value.
type FlagOccurrence struct {
Name string // canonical flag name
Spelling string // entered long spelling or individual short spelling
RawValue string // unconverted value collected by the parser
}
// ReportingMode selects visibility for shared reporting messages.
type ReportingMode uint8
const (
ReportingModeNormal ReportingMode = iota
ReportingModeQuiet
ReportingModeVerbose
ReportingModeDebug
)
// ReportingSelection contains the resolved reporting options for one parse.
type ReportingSelection struct {
Mode ReportingMode
Progress bool
ProgressSet bool
}
// Result contains the selected command and raw input collected by the adapter.
type Result struct {
Key Key
Path []string
HelpRequested bool
FlagValues [][]string
Operands []string
Failure *Failure
Key Key
Path []string
HelpRequested bool
FlagValues [][]string
FlagSpellings [][]string
FlagOccurrences []FlagOccurrence
Reporting ReportingSelection
Operands []string
Failure *Failure
}
// ParseOptions makes parser context and streams explicit.
type ParseOptions struct {
Context context.Context
In io.Reader
Out io.Writer
Err io.Writer
// FlagAliasesForPath selects aliases for the actual parser context when
// routing has not already selected one. It overrides FlagAliases when set.
FlagAliasesForPath func([]string) []FlagAlias
Context context.Context
In io.Reader
Out io.Writer
Err io.Writer
FlagAliases []FlagAlias
}

View File

@@ -0,0 +1,28 @@
package commandmeta
import "testing"
func TestReportingFlagsAreBooleanAndReturnedIndependently(t *testing.T) {
flags := ReportingFlags()
want := []string{"quiet", "verbose", "debug", "progress"}
if len(flags) != len(want) {
t.Fatalf("reporting flags = %#v", flags)
}
for index, name := range want {
if flags[index].Name != name || !flags[index].Boolean || flags[index].Short != "" || flags[index].Default != "false" {
t.Errorf("reporting flag %d = %#v", index, flags[index])
}
if !IsReportingFlag(name) {
t.Errorf("%q is not recognized as a reporting flag", name)
}
}
flags[0].Name = "changed"
if ReportingFlags()[0].Name != "quiet" {
t.Fatal("caller mutation changed shared reporting metadata")
}
for _, name := range []string{"help", "quietly", ""} {
if IsReportingFlag(name) {
t.Errorf("%q was incorrectly recognized as a reporting flag", name)
}
}
}

View File

@@ -0,0 +1,106 @@
// Package commandpath provides the shared representation and operations for
// command paths used by CommandKit's core and backend adapter.
package commandpath
import (
"fmt"
"strconv"
"strings"
)
// Path is a sequence of command-name tokens. The empty path identifies the
// application root.
type Path []string
// ParseDeclaration parses a whitespace-separated command path for capture.
// Declaration paths must contain at least one component.
func ParseDeclaration(value string) (Path, error) {
path := Path(strings.Fields(value))
if len(path) == 0 {
return nil, fmt.Errorf("command path %q must contain at least one name", value)
}
for _, component := range path {
if err := ValidateComponent(component); err != nil {
return nil, err
}
}
return path.Clone(), nil
}
// ParseLookup parses a whitespace-separated lookup path. An empty or
// whitespace-only value selects the application root.
func ParseLookup(value string) (Path, error) {
path := Path(strings.Fields(value))
for _, component := range path {
if err := ValidateComponent(component); err != nil {
return nil, err
}
}
return path.Clone(), nil
}
// FromTokens makes an owned path copy without splitting or trimming tokens.
func FromTokens(tokens []string) Path {
return Path(tokens).Clone()
}
// Clone returns an owned copy of the path.
func (path Path) Clone() Path {
return append(Path(nil), path...)
}
// CloneTokens returns an owned copy of token data.
func CloneTokens(tokens []string) []string {
return append([]string(nil), tokens...)
}
// ValidateComponent validates one canonical command-name token.
func ValidateComponent(name string) error {
fields := strings.Fields(name)
if len(fields) != 1 || fields[0] != name || strings.HasPrefix(name, "-") || name == "--" {
return fmt.Errorf("command name %q must be one non-option token", name)
}
return nil
}
// Equal reports whether both paths contain the same components.
func (path Path) Equal(other Path) bool {
if len(path) != len(other) {
return false
}
for index := range path {
if path[index] != other[index] {
return false
}
}
return true
}
// HasPrefix reports whether prefix is a component-wise prefix of path.
func (path Path) HasPrefix(prefix Path) bool {
if len(prefix) > len(path) {
return false
}
for index := range prefix {
if path[index] != prefix[index] {
return false
}
}
return true
}
// Key returns a normalized, unambiguous key for this path.
func (path Path) Key() string {
var key strings.Builder
for _, component := range path {
key.WriteString(strconv.Itoa(len(component)))
key.WriteByte(':')
key.WriteString(component)
}
return key.String()
}
// String formats the path for diagnostics and help text.
func (path Path) String() string {
return strings.Join(path, " ")
}

View File

@@ -0,0 +1,80 @@
package commandpath
import "testing"
func TestParseDeclarationAndLookup(t *testing.T) {
declaration, err := ParseDeclaration(" config\tshow ")
if err != nil {
t.Fatal(err)
}
if got, want := declaration.String(), "config show"; got != want {
t.Fatalf("declaration string = %q, want %q", got, want)
}
if _, err := ParseDeclaration(" \t "); err == nil {
t.Fatal("ParseDeclaration accepted an empty path")
}
root, err := ParseLookup(" \t ")
if err != nil {
t.Fatal(err)
}
if len(root) != 0 || root.String() != "" {
t.Fatalf("empty lookup path = %#v, string %q", root, root.String())
}
lookup, err := ParseLookup("config show")
if err != nil || !lookup.Equal(declaration) {
t.Fatalf("lookup = %#v, err %v; want %#v", lookup, err, declaration)
}
quoted := `say"hello C:\tmp`
parsed, err := ParseDeclaration(quoted)
if err != nil {
t.Fatal(err)
}
if got := parsed.String(); got != quoted {
t.Fatalf("quoted path = %q, want literal %q", got, quoted)
}
}
func TestValidateComponent(t *testing.T) {
for _, value := range []string{"--help", "-x", "--", "name word", "name\tpart"} {
t.Run(value, func(t *testing.T) {
if err := ValidateComponent(value); err == nil {
t.Fatalf("ValidateComponent(%q) succeeded", value)
}
})
}
if err := ValidateComponent("under_score"); err != nil {
t.Fatalf("existing valid component rejected: %v", err)
}
}
func TestPathRelationsAndKey(t *testing.T) {
path := Path{"config", "show"}
if !path.Equal(Path{"config", "show"}) {
t.Fatal("equal paths did not compare equal")
}
if !path.HasPrefix(Path{"config"}) || !path.HasPrefix(nil) {
t.Fatal("path did not recognize component prefix")
}
if path.HasPrefix(Path{"show"}) || (Path{"config"}).HasPrefix(path) {
t.Fatal("path recognized a non-prefix")
}
if (Path{"a\x00b"}).Key() == (Path{"a", "b"}).Key() {
t.Fatal("normalized keys collided for distinct component sequences")
}
}
func TestTokenCopiesPreserveTokenContents(t *testing.T) {
input := []string{" show ", "", "--", "-x=value"}
path := FromTokens(input)
input[0] = "changed"
if path[0] != " show " {
t.Fatalf("FromTokens did not copy input: %#v", path)
}
copy := CloneTokens(path)
copy[1] = "changed"
if path[1] != "" {
t.Fatalf("CloneTokens did not copy input: %#v", path)
}
}

View File

@@ -1,18 +1,24 @@
package commandkit
import (
"errors"
"io"
"strings"
)
// Severity classifies a neutral diagnostic.
// Severity classifies a nonterminal provider diagnostic. Terminal failure
// context belongs in an Outcome explanation instead.
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 +35,163 @@ 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 writer supplied by the reporter.
// Use that writer only during the callback. A callback must not submit another
// message, queue deferred work, or start another rendering operation through
// the same invocation's reporter.
type Detail func(io.Writer) error
// Explanation contains terminal content for a failed outcome. CommandKit
// presents it during invocation finalization, including in quiet mode.
type Explanation struct {
// Message is the literal opening text.
Message string
// Details render additional content in order.
Details []Detail
}
// PresentationResult records output and diagnostic delivery observations
// populated by CommandKit during invocation finalization. It is not a message
// submission path or a transcript.
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.
// The state distinguishes no submitted content, intentionally suppressed
// content, completed delivery, and incomplete delivery. RenderError and
// WriteError retain the first error of their kind for that 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. It
// does not emit output or select an invocation outcome; the owning integration
// submits it through the invocation reporter.
//
// 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. The owning command chooses how to submit each diagnostic and whether
// to attach the explanation to a failed outcome.
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.
// Outcome is the classified result of an operation. Constructing or returning
// an outcome does not itself write output; Execute presents its explanation
// during finalization.
//
// 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. Presentation failures change a success or usage outcome to
// operational status 1; a domain outcome keeps its classification and nonzero
// status, while a zero domain status becomes 1.
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. The message and details are presented once when the outcome is
// returned from an invocation; an optional operation error can be attached with
// WithCause and is not rendered automatically.
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. The
// message and details are presented when the outcome is returned from an
// invocation; an optional cause is preserved separately with WithCause.
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. The application owns the classification and status decision.
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, replacing any existing explanation. CommandKit presents it once
// during finalization, including in quiet mode.
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 operation
// cause. The cause is preserved for caller inspection and does not select an
// outcome or generate operator-facing text.
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,

View File

@@ -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)
}
}

View File

@@ -1,17 +1,60 @@
package commandkit
import (
"fmt"
"sort"
"strconv"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
func (a *App) parse(args []string, options commandmeta.ParseOptions) commandmeta.Result {
if a == nil {
return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
}
return internalcobra.Parse(a.routeSnapshot().metadata, args, options)
result := internalcobra.Parse(a.routeSnapshot().metadata, args, options)
return reduceReportingOccurrences(result)
}
func reduceReportingOccurrences(result commandmeta.Result) commandmeta.Result {
selection := commandmeta.ReportingSelection{}
for index, occurrence := range result.FlagOccurrences {
if !commandmeta.IsReportingFlag(occurrence.Name) {
continue
}
enabled, err := strconv.ParseBool(occurrence.RawValue)
if err != nil {
result.FlagOccurrences = result.FlagOccurrences[:index+1]
result.Reporting = selection
result.Failure = &commandmeta.Failure{
Message: fmt.Sprintf("invalid boolean value %q for %s", occurrence.RawValue, occurrence.Spelling),
Usage: true,
}
return result
}
mode := commandmeta.ReportingModeNormal
switch occurrence.Name {
case "quiet":
mode = commandmeta.ReportingModeQuiet
case "verbose":
mode = commandmeta.ReportingModeVerbose
case "debug":
mode = commandmeta.ReportingModeDebug
case "progress":
selection.Progress = enabled
selection.ProgressSet = true
continue
}
if enabled {
selection.Mode = mode
} else if selection.Mode == mode {
selection.Mode = commandmeta.ReportingModeNormal
}
}
result.Reporting = selection
return result
}
func (a *App) routeSnapshot() *routingSnapshot {
@@ -25,8 +68,27 @@ func (a *App) routeSnapshot() *routingSnapshot {
}
func (a *App) buildRoutingSnapshot() *routingSnapshot {
root := &commandmeta.Command{Name: a.identity.Name}
snapshot := &routingSnapshot{metadata: root, runners: make(map[commandmeta.Key]commandPayload)}
root := &commandmeta.Command{Name: a.identity.Name, CoreFlags: commandmeta.ReportingFlags()}
snapshot := &routingSnapshot{
metadata: root,
runners: make(map[commandmeta.Key]commandPayload),
aliases: a.aliases,
canonical: buildCanonicalTree(a.commands),
flagAliases: a.flagAliases,
discoveryFlags: a.discoveryFlags,
}
for _, rule := range a.routingRules {
if rule.record.kind != commandAliasRule {
continue
}
snapshot.commandAliases = append(snapshot.commandAliases, commandmeta.CommandAlias{
Source: []string(rule.record.sourcePath.Clone()),
Target: []string(snapshot.aliases.Resolve(rule.record.targetPath)),
})
}
sort.Slice(snapshot.commandAliases, func(i, j int) bool {
return commandpath.FromTokens(snapshot.commandAliases[i].Source).Key() < commandpath.FromTokens(snapshot.commandAliases[j].Source).Key()
})
names := make([]string, 0, len(a.commands))
for name := range a.commands {
names = append(names, name)
@@ -47,6 +109,7 @@ func bridgeCommand(command Command, snapshot *routingSnapshot, next *commandmeta
Name: record.name,
Summary: record.summary,
Description: record.description,
CoreFlags: commandmeta.ReportingFlags(),
}
if record.payload != nil {
snapshot.runners[declaration.Key] = record.payload

126
parser_reporting_test.go Normal file
View File

@@ -0,0 +1,126 @@
package commandkit
import (
"reflect"
"testing"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
)
func newReportingParseApp(t *testing.T) *App {
t.Helper()
app, err := New(Identity{Name: "Example"},
flagAliasCommand("run", stringFlag("message", "")),
FlagAlias("-q", "--quiet"),
FlagAlias("-v", "--verbose"),
FlagAlias("-d", "--debug"),
)
if err != nil {
t.Fatal(err)
}
return app
}
func parseReportingArgs(app *App, args []string) commandmeta.Result {
route := resolveEnteredRoute(args, app.routeSnapshot())
current := &execution{app: app, route: route}
return current.parse(route.args)
}
func TestReportingModeReductionFollowsFlagOccurrenceOrder(t *testing.T) {
for _, test := range []struct {
name string
args []string
mode commandmeta.ReportingMode
}{
{"quiet then verbose", []string{"run", "--quiet", "--verbose"}, commandmeta.ReportingModeVerbose},
{"debug then quiet", []string{"run", "--debug", "--quiet"}, commandmeta.ReportingModeQuiet},
{"repeated verbose", []string{"run", "--verbose", "--verbose"}, commandmeta.ReportingModeVerbose},
{"clear selected repeated mode", []string{"run", "--verbose", "--verbose=false"}, commandmeta.ReportingModeNormal},
{"clear other mode has no effect", []string{"run", "--quiet", "--verbose=false"}, commandmeta.ReportingModeQuiet},
{"clear current after switch", []string{"run", "--verbose", "--quiet", "--quiet=false"}, commandmeta.ReportingModeNormal},
{"disabled quiet then debug", []string{"run", "--quiet=false", "--debug"}, commandmeta.ReportingModeDebug},
{"disabled quiet leaves verbose", []string{"run", "--verbose", "--quiet=false"}, commandmeta.ReportingModeVerbose},
} {
t.Run(test.name, func(t *testing.T) {
result := parseReportingArgs(newReportingParseApp(t), test.args)
if result.Failure != nil || result.Reporting.Mode != test.mode {
t.Fatalf("parse result = %#v, want mode %d", result, test.mode)
}
})
}
}
func TestProgressSelectionIsIndependentAndUsesLastOccurrence(t *testing.T) {
for _, test := range []struct {
name string
args []string
mode commandmeta.ReportingMode
progress bool
progressSet bool
}{
{"explicit false before debug", []string{"run", "--progress=false", "--debug"}, commandmeta.ReportingModeDebug, false, true},
{"explicit false after debug", []string{"run", "--debug", "--progress=false"}, commandmeta.ReportingModeDebug, false, true},
{"explicit true in verbose", []string{"run", "--verbose", "--progress"}, commandmeta.ReportingModeVerbose, true, true},
{"quiet keeps independent setting", []string{"run", "--progress", "--quiet"}, commandmeta.ReportingModeQuiet, true, true},
{"unset by default", []string{"run", "--verbose"}, commandmeta.ReportingModeVerbose, false, false},
} {
t.Run(test.name, func(t *testing.T) {
result := parseReportingArgs(newReportingParseApp(t), test.args)
selection := result.Reporting
if result.Failure != nil || selection.Mode != test.mode || selection.Progress != test.progress || selection.ProgressSet != test.progressSet {
t.Fatalf("parse selection = %#v, failure = %v", selection, result.Failure)
}
})
}
}
func TestReportingParseRetainsAliasesAndConsumedPrefix(t *testing.T) {
app := newReportingParseApp(t)
result := parseReportingArgs(app, []string{"run", "--verbose", "-q"})
if result.Failure != nil || result.Reporting.Mode != commandmeta.ReportingModeQuiet {
t.Fatalf("aliased selection = %#v, failure=%+v", result, result.Failure)
}
want := []commandmeta.FlagOccurrence{
{Name: "verbose", Spelling: "--verbose", RawValue: "true"},
{Name: "quiet", Spelling: "-q", RawValue: "true"},
}
if !reflect.DeepEqual(result.FlagOccurrences, want) {
t.Fatalf("occurrences = %#v, want %#v", result.FlagOccurrences, want)
}
result = parseReportingArgs(app, []string{"run", "--verbose", "--unknown", "--quiet"})
if result.Failure == nil || result.Reporting.Mode != commandmeta.ReportingModeVerbose || len(result.FlagOccurrences) != 1 {
t.Fatalf("unknown flag partial selection = %#v", result)
}
result = parseReportingArgs(app, []string{"run", "--verbose", "--quiet=invalid", "--debug"})
if result.Failure == nil || !result.Failure.Usage || result.Reporting.Mode != commandmeta.ReportingModeVerbose || len(result.FlagOccurrences) != 2 {
t.Fatalf("invalid boolean partial selection = %#v", result)
}
}
func TestReportingParsePreservesValuesHelpAndTerminator(t *testing.T) {
app := newReportingParseApp(t)
result := parseReportingArgs(app, []string{"run", "--message=--quiet", "--verbose", "--", "--debug"})
if result.Failure != nil || result.Reporting.Mode != commandmeta.ReportingModeVerbose || !reflect.DeepEqual(result.Operands, []string{"--debug"}) {
t.Fatalf("option-looking value or terminator = %#v", result)
}
if len(result.FlagOccurrences) != 2 || result.FlagOccurrences[0].Name != "message" || result.FlagOccurrences[0].RawValue != "--quiet" {
t.Fatalf("reporting-like value became a flag occurrence: %#v", result.FlagOccurrences)
}
result = parseReportingArgs(app, []string{"run", "--help=bad", "--quiet"})
if result.Failure == nil || result.Reporting.Mode != commandmeta.ReportingModeNormal || len(result.FlagOccurrences) != 1 || result.FlagOccurrences[0].Name != "help" {
t.Fatalf("invalid help boolean consumed later flags: %#v", result)
}
result = parseReportingArgs(app, []string{"run", "--help=false", "--quiet"})
if result.Failure != nil || result.HelpRequested || result.Reporting.Mode != commandmeta.ReportingModeQuiet {
t.Fatalf("valid false help boolean changed handling: %#v", result)
}
result = parseReportingArgs(app, []string{"missing", "--quiet"})
if result.Failure == nil || result.Reporting.Mode != commandmeta.ReportingModeQuiet || len(result.FlagOccurrences) != 1 || result.FlagOccurrences[0].Name != "quiet" {
t.Fatalf("routing failure did not retain reporting selection: %#v", result)
}
}

View File

@@ -102,7 +102,7 @@ func BenchmarkPerformanceRegistration(b *testing.B) {
if err != nil {
b.Fatal(err)
}
if err := app.Register(commands...); err != nil {
if err := app.Register(performanceDeclarations(commands)...); err != nil {
b.Fatal(err)
}
}
@@ -124,7 +124,7 @@ func performanceFlatCommands(tb testing.TB, count int, prefix string) []commandk
run := func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }
if prefix == "cmd" && index == 4 {
run = func(commandkit.Invocation, struct{}) commandkit.Outcome {
return commandkit.Usage(performanceUsageCause)
return commandkit.Usage("The benchmark command needs usage.").WithCause(performanceUsageCause)
}
}
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: name, Summary: "Benchmark command.", Flags: flags, Run: run})
@@ -150,13 +150,21 @@ func performanceNestedCommand(tb testing.TB) (commandkit.Command, []string) {
func performanceApp(tb testing.TB, commands []commandkit.Command) *commandkit.App {
tb.Helper()
app, err := commandkit.New(commandkit.Identity{Name: "bench"}, commands...)
app, err := commandkit.New(commandkit.Identity{Name: "bench"}, performanceDeclarations(commands)...)
if err != nil {
tb.Fatal(err)
}
return app
}
func performanceDeclarations(commands []commandkit.Command) []commandkit.Declaration {
declarations := make([]commandkit.Declaration, len(commands))
for index, command := range commands {
declarations[index] = command
}
return declarations
}
func performanceCommandName(fixture string) string {
if fixture == "wide" {
return "wide00"

View File

@@ -1,46 +1,354 @@
package commandkit
import "fmt"
import (
"errors"
"fmt"
"sort"
// Register adds a batch of root commands to the application. The complete batch
// is validated before registry mutation; on error the registry and its derived
// command metadata remain unchanged. A successful nonempty batch becomes
// visible to later invocations. Command declarations remain reusable.
func (a *App) Register(commands ...Command) error {
return a.installBatch(commands...)
internalalias "gitea.maximumdirect.net/eric/CommandKit/internal/alias"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
// Register adds a batch of canonical commands and routing rules to the
// application. The complete batch is validated before registry mutation; on
// error the registry and its derived command metadata remain unchanged. A
// successful nonempty batch becomes visible to later invocations. Declarations
// remain reusable. Identical duplicate routing mappings are accepted, while
// competing targets for the same source are errors. Nil and zero declarations
// are invalid; use NoCommand for an intentionally absent optional command.
func (a *App) Register(declarations ...Declaration) error {
return a.installBatch(declarations...)
}
func (a *App) installBatch(commands ...Command) error {
type installationCandidate struct {
commands map[string]*commandNode
routingRules []RoutingRule
aliases *internalalias.Resolver
flagAliases map[string]resolvedFlagAliases
discoveryFlags map[string]resolvedFlagAliases
changed bool
}
type canonicalNode struct {
command Command
path commandpath.Path
children map[string]*canonicalNode
}
type commandAliasTrace struct {
exact *canonicalNode
operandOwner *canonicalNode
}
type routingRuleKey struct {
kind routingRuleKind
source string
target string
scope string
}
func (a *App) installBatch(declarations ...Declaration) error {
if a == nil {
return fmt.Errorf("cannot register commands on a nil app")
}
commands := make([]Command, 0, len(declarations))
rules := make([]RoutingRule, 0)
for _, declaration := range declarations {
switch value := declaration.(type) {
case Command:
commands = append(commands, value)
case *Command:
if value == nil {
return fmt.Errorf("nil *Command declaration")
}
commands = append(commands, *value)
case RoutingRule:
rules = append(rules, value)
case *RoutingRule:
if value == nil {
return fmt.Errorf("nil *RoutingRule declaration")
}
rules = append(rules, *value)
case nil:
return fmt.Errorf("nil declaration")
default:
return fmt.Errorf("unsupported declaration type %T", declaration)
}
}
pending := make(map[string]*commandNode, len(commands))
candidate, err := a.buildInstallationCandidate(commands, rules)
if err != nil {
return err
}
if !candidate.changed {
return nil
}
a.commands = candidate.commands
a.routingRules = candidate.routingRules
a.aliases = candidate.aliases
a.flagAliases = candidate.flagAliases
a.discoveryFlags = candidate.discoveryFlags
a.snapshot = nil
return nil
}
func (a *App) buildInstallationCandidate(commands []Command, addedRules []RoutingRule) (*installationCandidate, error) {
candidate := &installationCandidate{
commands: make(map[string]*commandNode, len(a.commands)+len(commands)),
}
for name, node := range a.commands {
candidate.commands[name] = node
}
for _, command := range commands {
if err := validateCommandPlacement(command); err != nil {
return err
return nil, err
}
if command.record.absent {
continue
}
name := command.record.name
if _, exists := a.commands[name]; exists {
return fmt.Errorf("command path %q is already registered", name)
if _, exists := candidate.commands[name]; exists {
return nil, fmt.Errorf("command path %q is already registered", name)
}
if _, exists := pending[name]; exists {
return fmt.Errorf("command path %q is already registered", name)
candidate.commands[name] = &commandNode{command: command}
candidate.changed = true
}
rules, err := normalizeRoutingRules(append(append([]RoutingRule(nil), a.routingRules...), addedRules...))
if err != nil {
return nil, err
}
candidate.routingRules = rules
if !sameRoutingRules(rules, a.routingRules) {
candidate.changed = true
}
var mappings []internalalias.Mapping
for _, rule := range rules {
if rule.record.kind == commandAliasRule {
mappings = append(mappings, internalalias.Mapping{
Source: rule.record.sourcePath.Clone(),
Target: rule.record.targetPath.Clone(),
})
}
pending[name] = &commandNode{command: command}
}
if len(pending) == 0 {
return nil
if err := internalalias.ValidateTermination(mappings); err != nil {
return nil, err
}
if a.commands == nil {
a.commands = make(map[string]*commandNode, len(pending))
resolver, err := internalalias.NewResolver(mappings)
if err != nil {
return nil, err
}
for name, node := range pending {
a.commands[name] = node
candidate.aliases = resolver
tree := buildCanonicalTree(candidate.commands)
if err := validateCommandAliases(rules, resolver, tree); err != nil {
return nil, err
}
flagAliases, discoveryFlags, err := compileFlagAliases(rules, tree, resolver)
if err != nil {
return nil, err
}
candidate.flagAliases = flagAliases
candidate.discoveryFlags = discoveryFlags
return candidate, nil
}
func normalizeRoutingRules(rules []RoutingRule) ([]RoutingRule, error) {
unique := make(map[routingRuleKey]RoutingRule, len(rules))
commandTargets := make(map[string]string)
type scopedFlagKey struct {
kind routingRuleKind
scope string
source string
}
flagTargets := make(map[scopedFlagKey]string)
for _, rule := range rules {
if rule.record == nil {
return nil, fmt.Errorf("invalid zero routing rule declaration")
}
if len(rule.record.issues) > 0 {
return nil, fmt.Errorf("routing rule: %w", errors.Join(rule.record.issues...))
}
key, err := routingKey(rule.record)
if err != nil {
return nil, err
}
if key.kind == commandAliasRule {
if previous, exists := commandTargets[key.source]; exists && previous != key.target {
return nil, fmt.Errorf("conflicting command aliases for %q", rule.record.sourcePath.String())
}
commandTargets[key.source] = key.target
} else {
scopeKey := scopedFlagKey{kind: key.kind, scope: key.scope, source: key.source}
if previous, exists := flagTargets[scopeKey]; exists && previous != key.target {
return nil, fmt.Errorf("conflicting flag aliases for %q", rule.record.sourceFlag.token())
}
flagTargets[scopeKey] = key.target
}
unique[key] = rule
}
keys := make([]routingRuleKey, 0, len(unique))
for key := range unique {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
left, right := keys[i], keys[j]
if left.kind != right.kind {
return left.kind < right.kind
}
if left.source != right.source {
return left.source < right.source
}
if left.scope != right.scope {
return left.scope < right.scope
}
return left.target < right.target
})
result := make([]RoutingRule, 0, len(keys))
for _, key := range keys {
result = append(result, unique[key])
}
return result, nil
}
func routingKey(record *routingRuleRecord) (routingRuleKey, error) {
key := routingRuleKey{kind: record.kind}
switch record.kind {
case commandAliasRule:
if len(record.sourcePath) == 0 || len(record.targetPath) == 0 {
return routingRuleKey{}, fmt.Errorf("invalid command alias path")
}
key.source = record.sourcePath.Key()
key.target = record.targetPath.Key()
case globalFlagAliasRule:
key.source = flagSpellingKey(record.sourceFlag)
key.target = flagSpellingKey(record.targetFlag)
case scopedFlagAliasRule:
if len(record.scopePath) == 0 {
return routingRuleKey{}, fmt.Errorf("invalid flag alias command scope")
}
key.source = flagSpellingKey(record.sourceFlag)
key.target = flagSpellingKey(record.targetFlag)
key.scope = record.scopePath.Key()
default:
return routingRuleKey{}, fmt.Errorf("unsupported routing rule kind %d", record.kind)
}
return key, nil
}
func flagSpellingKey(spelling capturedFlagSpelling) string {
if spelling.short {
return "short:" + spelling.name
}
return "long:" + spelling.name
}
func sameRoutingRules(left, right []RoutingRule) bool {
if len(left) != len(right) {
return false
}
leftKeys := make(map[routingRuleKey]struct{}, len(left))
for _, rule := range left {
key, err := routingKey(rule.record)
if err != nil {
return false
}
leftKeys[key] = struct{}{}
}
for _, rule := range right {
key, err := routingKey(rule.record)
if err != nil {
return false
}
if _, exists := leftKeys[key]; !exists {
return false
}
}
return true
}
func buildCanonicalTree(commands map[string]*commandNode) map[string]*canonicalNode {
roots := make(map[string]*canonicalNode, len(commands))
for name, node := range commands {
roots[name] = buildCanonicalNode(node.command, commandpath.Path{name})
}
return roots
}
func buildCanonicalNode(command Command, path commandpath.Path) *canonicalNode {
node := &canonicalNode{command: command, path: path.Clone(), children: make(map[string]*canonicalNode, len(command.record.children))}
for _, child := range command.record.children {
if child.record == nil || child.record.absent {
continue
}
childPath := append(path.Clone(), child.record.name)
node.children[child.record.name] = buildCanonicalNode(child, childPath)
}
return node
}
func validateCommandAliases(rules []RoutingRule, resolver *internalalias.Resolver, roots map[string]*canonicalNode) error {
for _, rule := range rules {
if rule.record.kind != commandAliasRule {
continue
}
record := rule.record
sourceTrace := traceCanonicalPath(roots, record.sourcePath)
if sourceTrace.exact != nil {
return fmt.Errorf("command alias source %q conflicts with canonical command %q", record.sourcePath.String(), sourceTrace.exact.path.String())
}
if sourceTrace.operandOwner != nil && acceptsOperandPrefix(sourceTrace.operandOwner.command, len(record.sourcePath)-len(sourceTrace.operandOwner.path)) {
return fmt.Errorf("command alias source %q intercepts operands of canonical command %q", record.sourcePath.String(), sourceTrace.operandOwner.path.String())
}
resolvedTarget := resolver.Resolve(record.targetPath)
trace := traceCanonicalPath(roots, resolvedTarget)
if trace.exact != nil {
continue
}
if trace.operandOwner != nil {
return fmt.Errorf("command alias %q target %q includes operands for canonical command %q", record.sourcePath.String(), record.targetPath.String(), trace.operandOwner.path.String())
}
return fmt.Errorf("command alias %q target %q does not resolve to a canonical command or group", record.sourcePath.String(), record.targetPath.String())
}
a.snapshot = nil
return nil
}
func traceCanonicalPath(roots map[string]*canonicalNode, path commandpath.Path) commandAliasTrace {
var current *canonicalNode
children := roots
for _, component := range path {
next := children[component]
if next != nil {
current = next
children = current.children
continue
}
if current != nil && current.command.record.payload != nil && current.command.record.payload.hasRun() {
return commandAliasTrace{operandOwner: current}
}
return commandAliasTrace{}
}
return commandAliasTrace{exact: current}
}
// acceptsOperandPrefix reports whether more operands could complete this prefix.
// A source need not already satisfy the command's minimum to intercept it.
func acceptsOperandPrefix(command Command, count int) bool {
if command.record == nil || command.record.payload == nil || !command.record.payload.hasRun() {
return false
}
arguments := command.record.payload.bridgeArguments()
maximum := 0
for _, argument := range arguments {
if maximum >= 0 {
if argument.Max < 0 {
maximum = -1
} else {
maximum += argument.Max
}
}
}
return maximum < 0 || count <= maximum
}

View File

@@ -2,7 +2,10 @@ package commandkit
import (
"errors"
"fmt"
"testing"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
type registryInput struct{ value string }
@@ -40,6 +43,266 @@ func TestCapturedDeclarationsRetainCallbacksWithoutCallingThem(t *testing.T) {
}
}
func TestRoutingRuleFactoriesCaptureNormalizedValues(t *testing.T) {
commandRule := Alias(" old\tpath ", " new ")
if got, want := commandRule.record.sourcePath.String(), "old path"; got != want {
t.Fatalf("source path = %q, want %q", got, want)
}
if got, want := commandRule.record.targetPath.String(), "new"; got != want {
t.Fatalf("target path = %q, want %q", got, want)
}
globalRule := FlagAlias("--colour", "-c")
if globalRule.record.sourceFlag != (capturedFlagSpelling{name: "colour"}) {
t.Fatalf("source spelling = %#v", globalRule.record.sourceFlag)
}
if globalRule.record.targetFlag != (capturedFlagSpelling{name: "c", short: true}) {
t.Fatalf("target spelling = %#v", globalRule.record.targetFlag)
}
scopedRule := FlagAliasFor(" new\t", "-f", "--help")
if got, want := scopedRule.record.scopePath.String(), "new"; got != want {
t.Fatalf("scope path = %q, want %q", got, want)
}
command := NewCommand(CommandSpec[struct{}]{Name: "new", Summary: "new"})
app, err := New(Identity{Name: "Example"}, &command, &commandRule, globalRule, scopedRule)
if err != nil {
t.Fatal(err)
}
if len(app.routingRules) != 3 {
t.Fatalf("captured routing rules = %d, want 3", len(app.routingRules))
}
registeredCommand := NewCommand(CommandSpec[struct{}]{Name: "registered", Summary: "registered"})
registeredRule := Alias("another", "registered")
if err := app.Register(&registeredCommand, &registeredRule); err != nil {
t.Fatal(err)
}
if len(app.routingRules) != 4 || app.commands["registered"] == nil {
t.Fatalf("pointer declarations were not normalized during Register: rules=%d command=%v", len(app.routingRules), app.commands["registered"])
}
}
func TestInvalidRoutingRuleValuesFailAtInstallation(t *testing.T) {
for name, rule := range map[string]RoutingRule{
"empty command source": Alias(" \t ", "target"),
"invalid command target": Alias("source", "--help"),
"flag assignment": FlagAlias("--format=yaml", "--format"),
"short cluster": FlagAlias("-xy", "--format"),
"invalid scope": FlagAliasFor("", "-f", "--format"),
} {
t.Run(name, func(t *testing.T) {
if _, err := New(Identity{Name: "Example"}, rule); err == nil {
t.Fatal("New() succeeded with an invalid routing rule")
}
})
}
}
func TestNilAndZeroDeclarationsFailWithoutMutation(t *testing.T) {
app, err := New(Identity{Name: "Example"})
if err != nil {
t.Fatal(err)
}
var nilCommand *Command
var nilRule *RoutingRule
declarations := []Declaration{nil, nilCommand, nilRule, Command{}, RoutingRule{}}
for index, declaration := range declarations {
if _, err := New(Identity{Name: "Example"}, declaration); err == nil {
t.Errorf("New() accepted invalid declaration %d", index)
}
beforeCommands := len(app.commands)
beforeRules := len(app.routingRules)
if err := app.Register(declaration); err == nil {
t.Errorf("Register() accepted invalid declaration %d", index)
}
if len(app.commands) != beforeCommands || len(app.routingRules) != beforeRules {
t.Errorf("invalid declaration %d mutated the app", index)
}
}
}
func TestAliasTargetsCanBeDeclaredInEitherOrder(t *testing.T) {
canonical := simpleRunnable("canonical")
for name, declarations := range map[string][]Declaration{
"targets last": {
Alias("shortcut", "alternate"),
Alias("alternate", "canonical"),
canonical,
},
"targets first": {
canonical,
Alias("alternate", "canonical"),
Alias("shortcut", "alternate"),
},
} {
t.Run(name, func(t *testing.T) {
app, err := New(Identity{Name: "Example"}, declarations...)
if err != nil {
t.Fatal(err)
}
if got := app.aliases.Resolve(commandpath.Path{"shortcut"}); !got.Equal(commandpath.Path{"canonical"}) {
t.Fatalf("shortcut resolves to %#v", got)
}
})
}
}
func TestIdenticalCommandAliasesAreIdempotentAcrossRegistrations(t *testing.T) {
app, err := New(Identity{Name: "Example"}, simpleRunnable("canonical"))
if err != nil {
t.Fatal(err)
}
rule := Alias("shortcut", "canonical")
if err := app.Register(rule, Alias(" shortcut ", " canonical ")); err != nil {
t.Fatal(err)
}
if len(app.routingRules) != 1 {
t.Fatalf("routing rules = %d, want one normalized rule", len(app.routingRules))
}
snapshot := app.routeSnapshot()
if err := app.Register(Alias("shortcut", "canonical")); err != nil {
t.Fatal(err)
}
if len(app.routingRules) != 1 || app.routeSnapshot() != snapshot {
t.Fatal("identical registration changed aliases or invalidated the snapshot")
}
}
func TestCommandAliasesRejectMissingAndCompetingTargets(t *testing.T) {
if _, err := New(Identity{Name: "Example"}, Alias("shortcut", "missing")); err == nil {
t.Fatal("New() accepted a missing alias target")
}
_, err := New(Identity{Name: "Example"},
simpleRunnable("canonical"),
Alias("first", "canonical"),
Alias("second", "canonical"),
Alias("shortcut", "first"),
Alias("shortcut", "second"),
)
if err == nil {
t.Fatal("New() accepted competing declared targets with a shared endpoint")
}
}
func TestCommandAliasesRejectCanonicalRoutesAndOperands(t *testing.T) {
group := Group("group", "group", simpleRunnable("show"))
if _, err := New(Identity{Name: "Example"}, group, simpleRunnable("target"), Alias("group show", "target")); err == nil {
t.Fatal("New() accepted an alias source that replaces a canonical child")
}
run := runnableWithOperands("run", 1, 1)
if _, err := New(Identity{Name: "Example"}, run, simpleRunnable("inspect"), Alias("run full", "inspect")); err == nil {
t.Fatal("New() accepted an alias source that intercepts a valid operand")
}
if _, err := New(Identity{Name: "Example"}, run, Alias("fast", "run production")); err == nil {
t.Fatal("New() accepted an operand-bearing alias target")
}
promptkit := Group("promptkit", "promptkit", runnableWithOperands("test", 1, 1))
if _, err := New(Identity{Name: "Example"}, promptkit, Alias("promptkit test full", "promptkit test all")); err == nil {
t.Fatal("New() accepted an alias that replaces the test command's KIND operand")
}
}
func TestCommandAliasesProtectIncompleteCanonicalOperandPrefixes(t *testing.T) {
for _, bounds := range []struct{ min, max int }{{2, 2}, {3, -1}} {
t.Run(fmt.Sprintf("%d_%d", bounds.min, bounds.max), func(t *testing.T) {
run := runnableWithOperands("run", bounds.min, bounds.max)
mapping := Alias("run first", "inspect")
if _, err := New(Identity{Name: "Example"}, run, simpleRunnable("inspect"), mapping); err == nil {
t.Fatal("construction accepted an alias intercepting an operand prefix")
}
for _, aliasFirst := range []bool{false, true} {
app, err := New(Identity{Name: "Example"}, simpleRunnable("inspect"))
if err != nil {
t.Fatal(err)
}
var first, second Declaration = run, mapping
if aliasFirst {
first, second = second, first
}
if err := app.Register(first); err != nil {
t.Fatal(err)
}
if err := app.Register(second, simpleRunnable("unrelated")); err == nil {
t.Fatal("later registration accepted an operand interception")
}
if app.commands["unrelated"] != nil || (app.commands["run"] != nil) == aliasFirst {
t.Fatal("rejected batch changed canonical registration")
}
if (len(app.routingRules) == 1) != aliasFirst {
t.Fatal("rejected batch changed alias registration")
}
}
})
}
}
func TestLongerAliasBeneathAlternateSpellingCanUseOperandWords(t *testing.T) {
app, err := New(Identity{Name: "Example"},
runnableWithOperands("run", 1, 1),
simpleRunnable("inspect"),
Alias("r", "run"),
Alias("r full", "inspect"),
Alias("run too many words", "inspect"),
)
if err != nil {
t.Fatal(err)
}
if got := app.aliases.Resolve(commandpath.Path{"r", "full"}); !got.Equal(commandpath.Path{"inspect"}) {
t.Fatalf("longer alternate route resolves to %#v", got)
}
}
func TestFailedAliasRegistrationPreservesCommandsRulesAndSnapshot(t *testing.T) {
app, err := New(Identity{Name: "Example"}, simpleRunnable("canonical"), Alias("existing", "canonical"))
if err != nil {
t.Fatal(err)
}
snapshot := app.routeSnapshot()
oldResolver := app.aliases
retained := errors.New("retained construction failure")
invalid := WithError(simpleRunnable("new"), fmt.Errorf("integration: %w", retained))
err = app.Register(invalid, Alias("later", "missing"))
if !errors.Is(err, retained) {
t.Fatalf("Register() error = %v, want retained cause", err)
}
if app.commands["new"] != nil || len(app.routingRules) != 1 || app.routeSnapshot() != snapshot || app.aliases != oldResolver {
t.Fatal("failed registration changed the app registry or derived metadata")
}
}
func TestNewCanonicalCommandIsCheckedAgainstExistingAliases(t *testing.T) {
app, err := New(Identity{Name: "Example"}, simpleRunnable("canonical"), Alias("shortcut", "canonical"))
if err != nil {
t.Fatal(err)
}
snapshot := app.routeSnapshot()
if err := app.Register(simpleRunnable("shortcut")); err == nil {
t.Fatal("Register() accepted a canonical command that collides with an alias source")
}
if app.commands["shortcut"] != nil || len(app.routingRules) != 1 || app.routeSnapshot() != snapshot {
t.Fatal("failed canonical registration changed the app registry or snapshot")
}
}
func simpleRunnable(name string) Command {
return NewCommand(CommandSpec[struct{}]{
Name: name, Summary: name,
Run: func(Invocation, struct{}) Outcome { return Success() },
})
}
func runnableWithOperands(name string, min, max int) Command {
return NewCommand(CommandSpec[struct{}]{
Name: name, Summary: name,
Arguments: []Argument[struct{}]{RepeatArgument(ArgumentMeta{Name: "ARG"}, min, max,
func(value string) (string, error) { return value, nil },
func(*struct{}, Supplied[[]string]) {},
)},
Run: func(Invocation, struct{}) Outcome { return Success() },
})
}
func TestAppsHaveIndependentRegistries(t *testing.T) {
first, err := New(Identity{Name: "First"})
if err != nil {

674
reporter.go Normal file
View File

@@ -0,0 +1,674 @@
package commandkit
import (
"errors"
"io"
"reflect"
"strings"
"sync"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
)
type reportingMode uint8
const (
modeOrdinary reportingMode = iota
modeQuiet
modeVerbose
modeDebug
)
type reportingDestination uint8
const (
destinationOutput reportingDestination = iota
destinationDiagnostics
)
type messageCategory uint8
const (
categoryInfo messageCategory = iota
categoryWarning
categoryDebug
categoryProgress
)
type reportingState struct {
mu sync.Mutex
output destinationState
diagnostics destinationState
closed bool
mode reportingMode
progress bool
progressSet bool
deferred []deferredMessage
}
type destinationState struct {
writer io.Writer
submitted bool
delivered bool
suppressed bool
renderError error
writeError error
}
type deferredMessage struct {
category messageCategory
message string
details []Detail
}
// Reporter coordinates presentation and delivery for one invocation. The
// invocation supplies it; consumers do not construct or finalize it. Reporting
// operations on one reporter are safe concurrently, although concurrent
// submissions have no predetermined order. Rendering callbacks must not
// reenter the same reporter, and their supplied writer is valid only while the
// callback runs.
//
// Consumers use Output for substantive command results and the reporting
// methods for categorized messages. Delivery and rendering failures are
// retained for Outcome.Presentation even when a caller ignores an immediate
// method's returned error.
type Reporter struct {
state *reportingState
}
// DeferredReporter queues messages for delivery during invocation finalization.
// Copies share the same invocation reporting state. Submissions made after
// finalization return ErrReporterClosed.
type DeferredReporter struct {
state *reportingState
}
type managedOutputWriter struct {
state *reportingState
destination reportingDestination
}
type coordinatedWriter struct {
state *reportingState
destination reportingDestination
writeError error
}
func (w *coordinatedWriter) Write(p []byte) (int, error) {
n, err := w.state.writeLocked(w.destination, p)
if err != nil && w.writeError == nil {
w.writeError = err
}
return n, err
}
func newReporter(streams Streams) *Reporter {
streams = normalizeStreams(streams)
return &Reporter{state: &reportingState{
output: destinationState{writer: streams.Out},
diagnostics: destinationState{writer: streams.Err},
mode: modeOrdinary,
}}
}
func (r *Reporter) applySelection(selection commandmeta.ReportingSelection) {
if r == nil || r.state == nil {
return
}
state := r.state
state.mu.Lock()
defer state.mu.Unlock()
if state.closed {
return
}
switch selection.Mode {
case commandmeta.ReportingModeQuiet:
state.mode = modeQuiet
case commandmeta.ReportingModeVerbose:
state.mode = modeVerbose
case commandmeta.ReportingModeDebug:
state.mode = modeDebug
default:
state.mode = modeOrdinary
}
state.progress = selection.Progress
state.progressSet = selection.ProgressSet
}
// Output returns a managed writer for substantive command results. Writes are
// serialized with other operations through this reporter, and failures are
// recorded in Outcome.Presentation. The writer returns ErrReporterClosed after
// invocation finalization.
func (r *Reporter) Output() io.Writer {
if r == nil || r.state == nil {
return managedOutputWriter{}
}
return managedOutputWriter{state: r.state, destination: destinationOutput}
}
func (w managedOutputWriter) Write(p []byte) (int, error) {
if w.state == nil {
return 0, ErrReporterClosed
}
return w.state.write(w.destination, p)
}
// RenderOutput coordinates a complete substantive report rendering operation.
// The callback receives a writer usable only during the callback and must not
// reenter this reporter. In quiet mode, the callback still runs against a
// discard destination so rendering failures are retained without writing to
// the caller's output destination.
func (r *Reporter) RenderOutput(write func(io.Writer) error) error {
if r == nil || r.state == nil {
return ErrReporterClosed
}
state := r.state
state.mu.Lock()
defer state.mu.Unlock()
if state.closed {
return ErrReporterClosed
}
if write == nil {
return nil
}
destination := state.destination(destinationOutput)
destination.submitted = true
writer := &coordinatedWriter{state: state, destination: destinationOutput}
renderErr := write(writer)
if state.mode == modeQuiet {
destination.suppressed = true
} else if renderErr == nil && writer.writeError == nil {
destination.delivered = true
}
return finishRenderLocked(state, destinationOutput, renderErr, writer.writeError)
}
// Info submits an informational message to the diagnostic destination. Its
// visibility follows the selected reporting mode.
func (r *Reporter) Info(message string, details ...Detail) error {
return r.submitMessage(categoryInfo, message, details)
}
// Warn submits a warning message to the diagnostic destination. Its visibility
// follows the selected reporting mode.
func (r *Reporter) Warn(message string, details ...Detail) error {
return r.submitMessage(categoryWarning, message, details)
}
// Debug submits a debug message to the diagnostic destination. Its visibility
// follows the selected reporting mode.
func (r *Reporter) Debug(message string, details ...Detail) error {
return r.submitMessage(categoryDebug, message, details)
}
// Progress submits a progress message to the diagnostic destination. Its
// visibility follows the selected mode and explicit progress setting.
func (r *Reporter) Progress(message string, details ...Detail) error {
return r.submitMessage(categoryProgress, message, details)
}
// AtEnd returns a view for messages delivered after the handler returns and
// before terminal explanation delivery. The deferred view shares this
// invocation's state.
func (r *Reporter) AtEnd() DeferredReporter {
if r == nil {
return DeferredReporter{}
}
return DeferredReporter{state: r.state}
}
// Info queues an informational message for invocation finalization. The
// submission error is returned immediately; rendering and delivery errors are
// reflected in the final outcome.
func (r DeferredReporter) Info(message string, details ...Detail) error {
return r.enqueue(categoryInfo, message, details)
}
// Warn queues a warning message for invocation finalization. The submission
// error is returned immediately; rendering and delivery errors are reflected in
// the final outcome.
func (r DeferredReporter) Warn(message string, details ...Detail) error {
return r.enqueue(categoryWarning, message, details)
}
// Debug queues a debug message for invocation finalization. The submission
// error is returned immediately; rendering and delivery errors are reflected in
// the final outcome.
func (r DeferredReporter) Debug(message string, details ...Detail) error {
return r.enqueue(categoryDebug, message, details)
}
func (r DeferredReporter) enqueue(category messageCategory, message string, details []Detail) error {
if r.state == nil {
return ErrReporterClosed
}
state := r.state
state.mu.Lock()
defer state.mu.Unlock()
if state.closed {
return ErrReporterClosed
}
if !hasDetails(details) {
if message == "" {
return nil
}
if !state.visible(category) {
state.diagnostics.submitted = true
state.diagnostics.suppressed = true
return nil
}
}
state.deferred = append(state.deferred, deferredMessage{
category: category,
message: message,
details: append([]Detail(nil), details...),
})
return nil
}
func (r *Reporter) finalize(outcome Outcome, completion func(*Reporter)) Outcome {
if r == nil || r.state == nil {
return normalizeOutcome(outcome)
}
state := r.state
state.mu.Lock()
if state.closed {
outcome.Presentation = state.presentationLocked()
state.mu.Unlock()
return normalizeOutcome(outcome)
}
deferred := state.deferred
state.deferred = nil
state.mu.Unlock()
for index := range deferred {
message := deferred[index]
deferred[index] = deferredMessage{}
state.mu.Lock()
_ = state.submitMessageLocked(message.category, message.message, message.details)
state.mu.Unlock()
}
if completion != nil {
completion(r)
}
state.mu.Lock()
if presentationFailedLocked(state) {
if outcome.Status == 0 && !hasExplanation(outcome.Explanation) {
outcome.Explanation.Message = presentationFailureMessage(state)
}
outcome = applyPresentationFailure(outcome)
}
if outcome.Status != 0 {
outcome = ensureFallbackExplanation(outcome)
if state.diagnostics.writeError == nil {
_ = renderTerminalLocked(state, outcome.Explanation)
}
}
if presentationFailedLocked(state) {
outcome = applyPresentationFailure(outcome)
}
state.closed = true
state.deferred = nil
outcome.Presentation = state.presentationLocked()
state.mu.Unlock()
return normalizeOutcome(outcome)
}
func normalizeOutcome(outcome Outcome) Outcome {
if outcome.Kind == "" && outcome.Status == 0 && outcome.Domain == "" {
outcome.Kind = OutcomeSuccess
}
return outcome
}
func presentationFailedLocked(state *reportingState) bool {
return state.output.renderError != nil || state.output.writeError != nil ||
state.diagnostics.renderError != nil || state.diagnostics.writeError != nil
}
func applyPresentationFailure(outcome Outcome) Outcome {
switch outcome.Kind {
case OutcomeDomain:
if outcome.Status == 0 {
outcome.Status = 1
}
case OutcomeSuccess, OutcomeUsage, OutcomeOperational:
outcome.Kind = OutcomeOperational
outcome.Status = 1
case "":
outcome.Kind = OutcomeOperational
outcome.Status = 1
}
return outcome
}
func ensureFallbackExplanation(outcome Outcome) Outcome {
if hasExplanation(outcome.Explanation) {
return outcome
}
outcome.Explanation = Explanation{Message: "Command failed."}
return outcome
}
func hasExplanation(explanation Explanation) bool {
return explanation.Message != "" || hasDetails(explanation.Details)
}
func hasDetails(details []Detail) bool {
for _, detail := range details {
if detail != nil {
return true
}
}
return false
}
func presentationFailureMessage(state *reportingState) string {
switch {
case state.output.renderError != nil:
return "Unable to render command output."
case state.output.writeError != nil:
return "Unable to deliver command output."
case state.diagnostics.renderError != nil:
return "Unable to render invocation diagnostics."
default:
return "Unable to deliver invocation diagnostics."
}
}
func renderTerminalLocked(state *reportingState, explanation Explanation) error {
destination := state.destination(destinationDiagnostics)
destination.submitted = true
var renderErr, writeErr error
if explanation.Message != "" {
writer := &coordinatedWriter{state: state, destination: destinationDiagnostics}
_, _ = writer.Write([]byte("ERROR: " + strings.TrimSuffix(explanation.Message, "\n") + "\n"))
writeErr = writer.writeError
}
if writeErr == nil {
for _, detail := range explanation.Details {
if detail == nil {
continue
}
writer := &coordinatedWriter{state: state, destination: destinationDiagnostics}
detailErr := detail(writer)
detailWriteErr := writer.writeError
detailRenderErr := recordRendererErrorLocked(state, destinationDiagnostics, detailErr, detailWriteErr)
if renderErr == nil {
renderErr = detailRenderErr
}
if writeErr == nil {
writeErr = detailWriteErr
}
}
}
if renderErr == nil && writeErr == nil {
destination.delivered = true
}
return combineOperationErrors(renderErr, writeErr)
}
func (r *Reporter) submitMessage(category messageCategory, message string, details []Detail) error {
if r == nil || r.state == nil {
return ErrReporterClosed
}
state := r.state
state.mu.Lock()
defer state.mu.Unlock()
if state.closed {
return ErrReporterClosed
}
return state.submitMessageLocked(category, message, append([]Detail(nil), details...))
}
// Queued detail lists have already been captured; delivery need not copy them.
func (state *reportingState) submitMessageLocked(category messageCategory, message string, details []Detail) error {
if state.closed {
return ErrReporterClosed
}
rich := hasDetails(details)
if message == "" && !rich {
return nil
}
visible := state.visible(category)
destination := state.destination(destinationDiagnostics)
destination.submitted = true
if !visible {
destination.suppressed = true
if !rich {
return nil
}
}
var renderErr, writeErr error
if visible && message != "" {
writer := &coordinatedWriter{state: state, destination: destinationDiagnostics}
_, writeErr = writer.Write([]byte(messageLine(category, message)))
writeErr = writer.writeError
}
if visible && writeErr != nil {
return writeErr
}
for _, detail := range details {
if detail == nil {
continue
}
var detailErr, detailWriteErr error
if visible {
writer := &coordinatedWriter{state: state, destination: destinationDiagnostics}
detailErr = detail(writer)
detailWriteErr = writer.writeError
} else {
detailErr = detail(io.Discard)
}
detailRenderErr := recordRendererErrorLocked(state, destinationDiagnostics, detailErr, detailWriteErr)
if renderErr == nil {
renderErr = detailRenderErr
}
if writeErr == nil {
writeErr = detailWriteErr
}
}
if visible && renderErr == nil && writeErr == nil {
destination.delivered = true
}
return combineOperationErrors(renderErr, writeErr)
}
func (state *reportingState) visible(category messageCategory) bool {
switch category {
case categoryInfo, categoryWarning:
return state.mode == modeVerbose || state.mode == modeDebug
case categoryDebug:
return state.mode == modeDebug
case categoryProgress:
if state.mode == modeQuiet {
return false
}
if state.progressSet {
return state.progress
}
return state.mode == modeDebug
default:
return false
}
}
func messageLine(category messageCategory, message string) string {
prefix := ""
switch category {
case categoryWarning:
prefix = "WARN: "
case categoryDebug:
prefix = "DEBUG: "
}
return prefix + strings.TrimSuffix(message, "\n") + "\n"
}
func finishRenderLocked(state *reportingState, destination reportingDestination, renderErr, writeErr error) error {
renderErr = recordRendererErrorLocked(state, destination, renderErr, writeErr)
return combineOperationErrors(renderErr, writeErr)
}
func recordRendererErrorLocked(state *reportingState, destination reportingDestination, renderErr, writeErr error) error {
if renderErr == nil || (writeErr != nil && propagatesOnlyWriteError(renderErr, writeErr)) {
return nil
}
state.recordRenderErrorLocked(destination, renderErr)
return renderErr
}
// A joined callback error may contain both a writer failure and independent
// rendering evidence. Walk each branch once rather than discarding the entire
// error merely because errors.Is finds the writer failure somewhere in it.
func propagatesOnlyWriteError(err, writeErr error) bool {
for err != nil {
if reflect.TypeOf(err).Comparable() && err == writeErr {
return true
}
switch wrapped := err.(type) {
case interface{ Unwrap() []error }:
found := false
for _, child := range wrapped.Unwrap() {
if child != nil {
found = true
if !propagatesOnlyWriteError(child, writeErr) {
return false
}
}
}
if found {
return true
}
case interface{ Unwrap() error }:
if child := wrapped.Unwrap(); child != nil {
err = child
continue
}
}
return errors.Is(err, writeErr)
}
return false
}
func combineOperationErrors(renderErr, writeErr error) error {
switch {
case renderErr == nil:
return writeErr
case writeErr == nil || errors.Is(renderErr, writeErr):
return renderErr
default:
return errors.Join(renderErr, writeErr)
}
}
func (state *reportingState) write(destination reportingDestination, p []byte) (int, error) {
state.mu.Lock()
defer state.mu.Unlock()
return state.writeLocked(destination, p)
}
// writeLocked is used by reporter operations that already hold state.mu.
func (state *reportingState) writeLocked(destination reportingDestination, p []byte) (int, error) {
if state.closed {
return 0, ErrReporterClosed
}
current := state.destination(destination)
current.submitted = true
if state.suppressed(destination) {
current.suppressed = true
return len(p), nil
}
if current.writeError != nil {
return 0, current.writeError
}
n, err := current.writer.Write(p)
if err == nil && n < len(p) {
err = io.ErrShortWrite
}
if err != nil && current.writeError == nil {
current.writeError = err
}
if err == nil {
current.delivered = true
}
return n, err
}
func (state *reportingState) destination(destination reportingDestination) *destinationState {
if destination == destinationDiagnostics {
return &state.diagnostics
}
return &state.output
}
func (state *reportingState) suppressed(destination reportingDestination) bool {
if destination == destinationOutput {
return state.mode == modeQuiet
}
return false
}
func (state *reportingState) recordRenderErrorLocked(destination reportingDestination, err error) {
if err == nil {
return
}
current := state.destination(destination)
if current.renderError == nil {
current.renderError = err
}
}
func (state *reportingState) presentationLocked() PresentationResult {
return PresentationResult{
Output: state.deliveryResultLocked(destinationOutput),
Diagnostics: state.deliveryResultLocked(destinationDiagnostics),
}
}
func (state *reportingState) presentation() PresentationResult {
state.mu.Lock()
defer state.mu.Unlock()
return state.presentationLocked()
}
func (state *reportingState) deliveryResultLocked(destination reportingDestination) DeliveryResult {
current := state.destination(destination)
result := DeliveryResult{RenderError: current.renderError, WriteError: current.writeError}
switch {
case current.renderError != nil || current.writeError != nil:
result.State = DeliveryIncomplete
case !current.submitted:
result.State = DeliveryNone
case current.delivered:
result.State = DeliveryComplete
case current.suppressed:
result.State = DeliverySuppressed
}
return result
}
func (r *Reporter) setVisibility(mode reportingMode, progress bool, progressSet bool) {
if r == nil || r.state == nil {
return
}
r.state.mu.Lock()
r.state.mode = mode
r.state.progress = progress
r.state.progressSet = progressSet
r.state.mu.Unlock()
}
func (r *Reporter) close() {
if r == nil || r.state == nil {
return
}
r.state.mu.Lock()
r.state.closed = true
r.state.mu.Unlock()
}

69
reporter_deferred_test.go Normal file
View File

@@ -0,0 +1,69 @@
package commandkit
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"testing"
)
func TestHiddenSimpleDeferredMessagesDoNotAccumulate(t *testing.T) {
for _, mode := range []reportingMode{modeOrdinary, modeQuiet} {
t.Run(fmt.Sprint(mode), func(t *testing.T) {
reporter := newReporter(Streams{})
reporter.setVisibility(mode, false, false)
for index := 0; index < 256; index++ {
if err := reporter.AtEnd().Debug(fmt.Sprintf("hidden %d", index), nil); err != nil {
t.Fatal(err)
}
if err := reporter.AtEnd().Warn("hidden warning"); err != nil {
t.Fatal(err)
}
}
// This assertion owns the storage optimization, not a public queue API.
if len(reporter.state.deferred) != 0 {
t.Fatalf("retained %d renderer-free hidden messages", len(reporter.state.deferred))
}
got := reporter.finalize(Success(), nil)
if got.Status != 0 || got.Presentation.Diagnostics.State != DeliverySuppressed {
t.Fatalf("outcome=%#v", got)
}
if err := reporter.AtEnd().Debug("late"); !errors.Is(err, ErrReporterClosed) {
t.Fatalf("closed hidden submission=%v", err)
}
})
}
noContent := newReporter(Streams{})
_ = noContent.AtEnd().Warn("", nil)
if got := noContent.finalize(Success(), nil); got.Presentation.Diagnostics.State != DeliveryNone {
t.Fatalf("empty deferred submission became content: %#v", got)
}
}
func TestHiddenDeferredDetailsRemainDeferredAndCaptured(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
reporter.setVisibility(modeQuiet, false, false)
failure := errors.New("hidden rendering failed")
var order []string
details := []Detail{func(w io.Writer) error {
order = append(order, "first")
_, _ = io.WriteString(w, "hidden detail")
return failure
}}
_ = reporter.AtEnd().Warn("hidden", details...)
details[0] = func(io.Writer) error { t.Error("caller changed captured callback"); return nil }
_ = reporter.AtEnd().Info("", func(io.Writer) error { order = append(order, "second"); return nil })
if len(order) != 0 {
t.Fatal("details ran at submission")
}
got := reporter.finalize(Success(), nil)
if strings.Join(order, ",") != "first,second" || !errors.Is(got.Presentation.Diagnostics.RenderError, failure) || got.Status != 1 {
t.Fatalf("order=%v outcome=%#v", order, got)
}
if strings.Contains(diagnostics.String(), "hidden") || len(reporter.state.deferred) != 0 {
t.Fatalf("hidden content leaked or queue retained: %q", diagnostics.String())
}
}

115
reporter_error_test.go Normal file
View File

@@ -0,0 +1,115 @@
package commandkit
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"testing"
)
func TestReporterExplainsItsOwnFailuresWithoutExposingCauses(t *testing.T) {
for _, kind := range []string{"output", "diagnostics"} {
for _, quiet := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/quiet=%t", kind, quiet), func(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
if quiet {
reporter.setVisibility(modeQuiet, false, false)
}
failure := errors.New("private rendering detail")
render := func(io.Writer) error { return failure }
if kind == "output" {
_ = reporter.RenderOutput(render)
} else {
_ = reporter.Warn("", render)
}
cause := errors.New("private application cause")
got := reporter.finalize(Domain("example", 0).WithCause(cause), nil)
if got.Kind != OutcomeDomain || got.Status != 1 || got.Cause != cause {
t.Fatalf("outcome=%#v", got)
}
if !strings.Contains(got.Explanation.Message, "render") || !strings.Contains(got.Explanation.Message, kind) || !strings.Contains(diagnostics.String(), got.Explanation.Message) {
t.Fatalf("explanation=%#v diagnostics=%q", got.Explanation, diagnostics.String())
}
if strings.Contains(diagnostics.String(), "private") {
t.Fatalf("private error text was exposed: %q", diagnostics.String())
}
})
}
}
}
func TestReporterPreservesExplicitSuccessExplanationOnPresentationFailure(t *testing.T) {
for _, message := range []string{"supplied context", ""} {
t.Run(message, func(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
_ = reporter.RenderOutput(func(io.Writer) error { return errors.New("render failed") })
calls := 0
got := reporter.finalize(Success().WithExplanation(message, func(io.Writer) error {
calls++
return nil
}), nil)
if got.Status != 1 || got.Explanation.Message != message || calls != 1 {
t.Fatalf("outcome=%#v calls=%d", got, calls)
}
if message == "" && diagnostics.Len() != 0 {
t.Fatalf("explicit zero-byte explanation replaced: %q", diagnostics.String())
}
})
}
}
func TestReporterPreservesJoinedRendererEvidence(t *testing.T) {
for _, kind := range []string{"output", "detail"} {
for _, test := range []struct {
name string
combine func(error, error) error
rendering bool
}{
{"joined", func(render, write error) error { return errors.Join(render, write) }, true},
{"wrapped join", func(render, write error) error {
return fmt.Errorf("rendering context: %w", errors.Join(write, render))
}, true},
{"plain write", func(_, write error) error { return write }, false},
{"wrapped write", func(_, write error) error { return fmt.Errorf("writing context: %w", write) }, false},
{"joined write only", func(_, write error) error { return errors.Join(write, fmt.Errorf("same writer: %w", write)) }, false},
} {
t.Run(kind+"/"+test.name, func(t *testing.T) {
renderErr := errors.New("independent rendering failure")
// The destination error can itself be a joined value. Returning that
// complete error still propagates a single destination failure.
writeErr := errors.Join(errors.New("sink failure"), errors.New("sink detail"))
destination := &reporterTestWriter{err: writeErr}
reporter := newReporter(Streams{Out: destination, Err: destination})
reporter.setVisibility(modeVerbose, false, false)
render := func(w io.Writer) error {
_, err := io.WriteString(w, "report")
return test.combine(renderErr, err)
}
var err error
if kind == "output" {
err = reporter.RenderOutput(render)
} else {
err = reporter.Warn("", render)
}
if !errors.Is(err, writeErr) || errors.Is(err, renderErr) != test.rendering {
t.Fatalf("returned error lost evidence: %v", err)
}
got := reporter.finalize(Success(), nil)
result := got.Presentation.Output
if kind == "detail" {
result = got.Presentation.Diagnostics
}
if result.State != DeliveryIncomplete || !errors.Is(result.WriteError, writeErr) || errors.Is(result.RenderError, renderErr) != test.rendering {
t.Fatalf("presentation=%#v", result)
}
if !test.rendering && result.RenderError != nil {
t.Fatalf("write propagation became rendering evidence: %v", result.RenderError)
}
})
}
}
}

697
reporter_test.go Normal file
View File

@@ -0,0 +1,697 @@
package commandkit
import (
"bytes"
"errors"
"io"
"strings"
"testing"
)
type reporterTestWriter struct {
writes int
n int
err error
output bytes.Buffer
}
func (w *reporterTestWriter) Write(p []byte) (int, error) {
w.writes++
if w.err != nil || w.n < len(p) {
n := w.n
if n > len(p) {
n = len(p)
}
if n > 0 {
_, _ = w.output.Write(p[:n])
}
return n, w.err
}
return w.output.Write(p)
}
func TestReporterOutputSubmitsOnWrite(t *testing.T) {
var destination bytes.Buffer
reporter := newReporter(Streams{Out: &destination})
writer := reporter.Output()
if got := reporter.state.presentation().Output.State; got != DeliveryNone {
t.Fatalf("state after obtaining output = %v, want no delivery", got)
}
if n, err := writer.Write([]byte("report")); err != nil || n != len("report") {
t.Fatalf("Write() = %d, %v", n, err)
}
if destination.String() != "report" {
t.Fatalf("output = %q", destination.String())
}
if got := reporter.state.presentation().Output.State; got != DeliveryComplete {
t.Fatalf("output state = %v, want complete", got)
}
}
type callerOwnedWriter struct {
bytes.Buffer
closeCalls int
}
func (w *callerOwnedWriter) Close() error {
w.closeCalls++
return nil
}
func TestReporterLeavesCallerWriterOpen(t *testing.T) {
destination := &callerOwnedWriter{}
reporter := newReporter(Streams{Out: destination})
_, _ = reporter.Output().Write([]byte("report"))
_ = reporter.RenderOutput(func(writer io.Writer) error {
_, err := io.WriteString(writer, " rendered")
return err
})
_ = reporter.finalize(Success(), nil)
if destination.String() != "report rendered" || destination.closeCalls != 0 {
t.Fatalf("destination = %q, Close calls = %d", destination.String(), destination.closeCalls)
}
}
func TestReporterQuietOutputDiscardsSuccessfully(t *testing.T) {
var destination bytes.Buffer
reporter := newReporter(Streams{Out: &destination})
reporter.setVisibility(modeQuiet, false, false)
writer := reporter.Output()
if n, err := writer.Write([]byte("hidden")); err != nil || n != len("hidden") {
t.Fatalf("quiet Write() = %d, %v", n, err)
}
if destination.Len() != 0 {
t.Fatalf("quiet output reached destination: %q", destination.String())
}
if got := reporter.state.presentation().Output.State; got != DeliverySuppressed {
t.Fatalf("output state = %v, want suppressed", got)
}
}
func TestReporterRetainsFirstWriteFailureAndStopsUsingDestination(t *testing.T) {
first := errors.New("first write failed")
second := errors.New("later write failed")
destination := &reporterTestWriter{n: 1, err: first}
reporter := newReporter(Streams{Out: destination})
writer := reporter.Output()
if n, err := writer.Write([]byte("first")); n != 1 || !errors.Is(err, first) {
t.Fatalf("first Write() = %d, %v", n, err)
}
if n, err := writer.Write([]byte("second")); n != 0 || !errors.Is(err, first) || errors.Is(err, second) {
t.Fatalf("second Write() = %d, %v, want retained first error", n, err)
}
if destination.writes != 1 {
t.Fatalf("destination writes = %d, want 1", destination.writes)
}
result := reporter.state.presentation().Output
if result.State != DeliveryIncomplete || !errors.Is(result.WriteError, first) {
t.Fatalf("delivery result = %#v", result)
}
}
func TestReporterNormalizesShortWriteAndRetainsIgnoredFailure(t *testing.T) {
destination := &reporterTestWriter{n: 2}
reporter := newReporter(Streams{Out: destination})
_, _ = reporter.Output().Write([]byte("short"))
result := reporter.state.presentation().Output
if result.State != DeliveryIncomplete || !errors.Is(result.WriteError, io.ErrShortWrite) {
t.Fatalf("delivery result = %#v, want short-write failure", result)
}
}
func TestReporterRetainsFirstRenderFailurePerDestination(t *testing.T) {
firstOutputErr := errors.New("first output render failed")
secondOutputErr := errors.New("later output render failed")
diagnosticErr := errors.New("diagnostic render failed")
reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard})
reporter.state.mu.Lock()
reporter.state.recordRenderErrorLocked(destinationOutput, firstOutputErr)
reporter.state.recordRenderErrorLocked(destinationOutput, secondOutputErr)
reporter.state.recordRenderErrorLocked(destinationDiagnostics, diagnosticErr)
reporter.state.mu.Unlock()
result := reporter.state.presentation()
if !errors.Is(result.Output.RenderError, firstOutputErr) || errors.Is(result.Output.RenderError, secondOutputErr) {
t.Fatalf("output render error = %v, want first error only", result.Output.RenderError)
}
if !errors.Is(result.Diagnostics.RenderError, diagnosticErr) {
t.Fatalf("diagnostic render error = %v", result.Diagnostics.RenderError)
}
if result.Output.State != DeliveryIncomplete || result.Diagnostics.State != DeliveryIncomplete {
t.Fatalf("render failures were not incomplete: %#v", result)
}
}
func TestReporterDestinationsTrackFailuresIndependently(t *testing.T) {
outputErr := errors.New("output failed")
output := &reporterTestWriter{n: 1, err: outputErr}
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Out: output, Err: &diagnostics})
_, _ = reporter.Output().Write([]byte("out"))
if n, err := reporter.state.write(destinationDiagnostics, []byte("warning")); err != nil || n != len("warning") {
t.Fatalf("diagnostic Write() = %d, %v", n, err)
}
if diagnostics.String() != "warning" {
t.Fatalf("diagnostic output = %q", diagnostics.String())
}
result := reporter.state.presentation()
if result.Output.State != DeliveryIncomplete || !errors.Is(result.Output.WriteError, outputErr) {
t.Fatalf("output result = %#v", result.Output)
}
if result.Diagnostics.State != DeliveryComplete || result.Diagnostics.WriteError != nil {
t.Fatalf("diagnostic result = %#v", result.Diagnostics)
}
}
func TestReporterClosedHandlesReturnClosedErrorIncludingQuiet(t *testing.T) {
var destination bytes.Buffer
reporter := newReporter(Streams{Out: &destination})
writer := reporter.Output()
reporter.setVisibility(modeQuiet, false, false)
reporter.close()
if n, err := writer.Write([]byte("late")); n != 0 || !errors.Is(err, ErrReporterClosed) {
t.Fatalf("late Write() = %d, %v", n, err)
}
if destination.Len() != 0 {
t.Fatalf("late output reached destination: %q", destination.String())
}
}
func TestNewReporterNormalizesNilDestinationsAndStartsOrdinary(t *testing.T) {
reporter := newReporter(Streams{})
if reporter.state.output.writer != io.Discard || reporter.state.diagnostics.writer != io.Discard {
t.Fatalf("nil destinations were not normalized: %#v", reporter.state)
}
if reporter.state.mode != modeOrdinary || reporter.state.progress || reporter.state.progressSet {
t.Fatalf("initial visibility = mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet)
}
reporter.setVisibility(modeDebug, true, true)
if reporter.state.mode != modeDebug || !reporter.state.progress || !reporter.state.progressSet {
t.Fatalf("visibility was not retained: mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet)
}
}
func TestReporterMessageVisibilityAndProgressPrecedence(t *testing.T) {
tests := []struct {
name string
mode reportingMode
progress bool
progressSet bool
want string
}{
{name: "ordinary", mode: modeOrdinary},
{name: "verbose", mode: modeVerbose, want: "not used\nWARN: not used\n"},
{name: "debug", mode: modeDebug, want: "not used\nWARN: not used\nDEBUG: DEBUG\nPROGRESS\n"},
{name: "quiet overrides progress", mode: modeQuiet, progress: true, progressSet: true},
{name: "explicit ordinary progress", mode: modeOrdinary, progress: true, progressSet: true, want: "PROGRESS\n"},
{name: "explicit debug disable", mode: modeDebug, progressSet: true, want: "not used\nWARN: not used\nDEBUG: DEBUG\n"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var output bytes.Buffer
reporter := newReporter(Streams{Err: &output})
reporter.setVisibility(test.mode, test.progress, test.progressSet)
_ = reporter.Info("not used")
_ = reporter.Warn("not used")
_ = reporter.Debug("DEBUG")
_ = reporter.Progress("PROGRESS")
if got := output.String(); got != test.want {
t.Fatalf("output = %q, want %q", got, test.want)
}
})
}
}
func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
reporter.setVisibility(modeVerbose, false, false)
if err := reporter.Warn("Native warning\nwith second line\n", nil,
func(w io.Writer) error { _, err := io.WriteString(w, "detail one\n"); return err },
func(w io.Writer) error { _, err := io.WriteString(w, "detail two\n"); return err }); err != nil {
t.Fatal(err)
}
if err := reporter.Info("", func(w io.Writer) error {
_, err := io.WriteString(w, "native labelled detail\n")
return err
}); err != nil {
t.Fatal(err)
}
want := "WARN: Native warning\nwith second line\ndetail one\ndetail two\nnative labelled detail\n"
if got := diagnostics.String(); got != want {
t.Fatalf("diagnostics = %q, want %q", got, want)
}
}
func TestReporterHiddenRenderersRunAgainstDiscardAndRetainErrors(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
reporter.setVisibility(modeQuiet, false, false)
renderErr := errors.New("native renderer failed")
calls := 0
err := reporter.Warn("hidden warning", func(w io.Writer) error {
calls++
if _, writeErr := io.WriteString(w, "hidden detail"); writeErr != nil {
return writeErr
}
return renderErr
})
if !errors.Is(err, renderErr) {
t.Fatalf("Warn() error = %v, want renderer error", err)
}
if calls != 1 || diagnostics.Len() != 0 {
t.Fatalf("hidden renderer calls=%d, destination=%q", calls, diagnostics.String())
}
result := reporter.state.presentation().Diagnostics
if result.State != DeliveryIncomplete || !errors.Is(result.RenderError, renderErr) || result.WriteError != nil {
t.Fatalf("diagnostic result = %#v", result)
}
}
func TestReporterRecordsIgnoredRenderErrors(t *testing.T) {
renderErr := errors.New("output renderer failed")
diagnosticErr := errors.New("diagnostic renderer failed")
reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard})
reporter.setVisibility(modeVerbose, false, false)
_ = reporter.RenderOutput(func(io.Writer) error { return renderErr })
_ = reporter.Warn("warning", func(io.Writer) error { return diagnosticErr })
result := reporter.state.presentation()
if !errors.Is(result.Output.RenderError, renderErr) || !errors.Is(result.Diagnostics.RenderError, diagnosticErr) {
t.Fatalf("ignored render errors were not retained: %#v", result)
}
}
func TestReporterRenderOutputRunsInQuietAndRetainsFailure(t *testing.T) {
destination := &reporterTestWriter{}
reporter := newReporter(Streams{Out: destination})
reporter.setVisibility(modeQuiet, false, false)
renderErr := errors.New("quiet encoder failed")
calls := 0
_ = reporter.RenderOutput(func(w io.Writer) error {
calls++
if _, err := io.WriteString(w, "discarded report"); err != nil {
return err
}
return renderErr
})
result := reporter.state.presentation().Output
if calls != 1 || destination.writes != 0 {
t.Fatalf("quiet rendering calls=%d destination writes=%d", calls, destination.writes)
}
if result.State != DeliveryIncomplete || !errors.Is(result.RenderError, renderErr) || result.WriteError != nil {
t.Fatalf("quiet render result = %#v", result)
}
}
func TestReporterCountsSuccessfulZeroByteRenderAsSubmitted(t *testing.T) {
reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard})
if err := reporter.RenderOutput(func(io.Writer) error { return nil }); err != nil {
t.Fatal(err)
}
reporter.setVisibility(modeVerbose, false, false)
if err := reporter.Warn("", func(io.Writer) error { return nil }); err != nil {
t.Fatal(err)
}
result := reporter.state.presentation()
if result.Output.State != DeliveryComplete || result.Diagnostics.State != DeliveryComplete {
t.Fatalf("zero-byte successful render states = %#v", result)
}
}
func TestReporterCountsHiddenZeroByteRenderAsSuppressed(t *testing.T) {
reporter := newReporter(Streams{Err: io.Discard})
if err := reporter.Warn("", func(io.Writer) error { return nil }); err != nil {
t.Fatal(err)
}
if got := reporter.state.presentation().Diagnostics.State; got != DeliverySuppressed {
t.Fatalf("hidden zero-byte render state = %v, want suppressed", got)
}
}
func TestReporterRenderOutputSeparatesRenderAndWriteFailures(t *testing.T) {
t.Run("renderer failure leaves destination usable", func(t *testing.T) {
var output bytes.Buffer
reporter := newReporter(Streams{Out: &output})
renderErr := errors.New("render failed")
if err := reporter.RenderOutput(func(io.Writer) error { return renderErr }); !errors.Is(err, renderErr) {
t.Fatalf("RenderOutput() error = %v", err)
}
if _, err := reporter.Output().Write([]byte("still usable")); err != nil {
t.Fatalf("destination rejected later write: %v", err)
}
result := reporter.state.presentation().Output
if !errors.Is(result.RenderError, renderErr) || result.WriteError != nil || output.String() != "still usable" {
t.Fatalf("result=%#v output=%q", result, output.String())
}
})
t.Run("propagated write error is not a renderer error", func(t *testing.T) {
writeErr := errors.New("writer failed")
destination := &reporterTestWriter{err: writeErr}
reporter := newReporter(Streams{Out: destination})
err := reporter.RenderOutput(func(w io.Writer) error {
_, writeErr := io.WriteString(w, "report")
return writeErr
})
if !errors.Is(err, writeErr) {
t.Fatalf("RenderOutput() error = %v", err)
}
result := reporter.state.presentation().Output
if result.RenderError != nil || !errors.Is(result.WriteError, writeErr) {
t.Fatalf("result = %#v", result)
}
})
t.Run("ignored write error is retained and returned", func(t *testing.T) {
writeErr := errors.New("writer failed")
reporter := newReporter(Streams{Out: &reporterTestWriter{err: writeErr}})
err := reporter.RenderOutput(func(w io.Writer) error {
_, _ = w.Write([]byte("report"))
return nil
})
if !errors.Is(err, writeErr) {
t.Fatalf("RenderOutput() error = %v", err)
}
})
t.Run("independent rendering and writing failures remain separate", func(t *testing.T) {
writeErr := errors.New("writer failed")
renderErr := errors.New("renderer failed independently")
reporter := newReporter(Streams{Out: &reporterTestWriter{err: writeErr}})
err := reporter.RenderOutput(func(w io.Writer) error {
_, _ = w.Write([]byte("report"))
return renderErr
})
if !errors.Is(err, writeErr) || !errors.Is(err, renderErr) {
t.Fatalf("RenderOutput() error = %v", err)
}
result := reporter.state.presentation().Output
if !errors.Is(result.WriteError, writeErr) || !errors.Is(result.RenderError, renderErr) {
t.Fatalf("result = %#v", result)
}
})
}
func TestReporterConcurrentOperationsRemainWhole(t *testing.T) {
t.Run("render callback and message", func(t *testing.T) {
var output bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &output})
reporter.setVisibility(modeVerbose, false, false)
start := make(chan struct{})
ready := make(chan struct{}, 2)
done := make(chan struct{}, 2)
go func() {
ready <- struct{}{}
<-start
_ = reporter.RenderOutput(func(w io.Writer) error {
_, _ = io.WriteString(w, "report-")
_, err := io.WriteString(w, "complete\n")
return err
})
done <- struct{}{}
}()
go func() {
ready <- struct{}{}
<-start
_ = reporter.Info("message")
done <- struct{}{}
}()
<-ready
<-ready
close(start)
<-done
<-done
got := output.String()
if got != "report-complete\nmessage\n" && got != "message\nreport-complete\n" {
t.Fatalf("concurrent output interleaved complete operations: %q", got)
}
})
t.Run("message details and render callback", func(t *testing.T) {
var output bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &output})
reporter.setVisibility(modeVerbose, false, false)
start := make(chan struct{})
ready := make(chan struct{}, 2)
done := make(chan struct{}, 2)
go func() {
ready <- struct{}{}
<-start
_ = reporter.Info("first", func(w io.Writer) error {
_, _ = io.WriteString(w, "detail-")
_, err := io.WriteString(w, "complete\n")
return err
})
done <- struct{}{}
}()
go func() {
ready <- struct{}{}
<-start
_ = reporter.RenderOutput(func(w io.Writer) error {
_, err := io.WriteString(w, "report\n")
return err
})
done <- struct{}{}
}()
<-ready
<-ready
close(start)
<-done
<-done
got := output.String()
if got != "first\ndetail-complete\nreport\n" && got != "report\nfirst\ndetail-complete\n" {
t.Fatalf("concurrent output interleaved complete operations: %q", got)
}
})
}
func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
reporter.setVisibility(modeVerbose, false, false)
if err := reporter.Warn("", func(w io.Writer) error {
_, err := io.WriteString(w, "native multiline\nsecond line\n")
return err
}); err != nil {
t.Fatal(err)
}
if got := diagnostics.String(); got != "native multiline\nsecond line\n" {
t.Fatalf("diagnostics = %q", got)
}
if strings.HasPrefix(diagnostics.String(), "WARN:") {
t.Fatal("empty heading added a category prefix")
}
}
func TestReporterFinalizesDeferredMessagesBeforeCompletionAndExplanation(t *testing.T) {
var output, diagnostics bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &diagnostics})
reporter.setVisibility(modeDebug, false, false)
if err := reporter.Info("started"); err != nil {
t.Fatal(err)
}
deferred := reporter.AtEnd()
if err := deferred.Warn("first deferred"); err != nil {
t.Fatal(err)
}
if err := deferred.Info("second deferred"); err != nil {
t.Fatal(err)
}
outcome := reporter.finalize(Failure("operation failed"), func(r *Reporter) {
_ = r.Debug("handler complete")
})
want := "started\nWARN: first deferred\nsecond deferred\nDEBUG: handler complete\nERROR: operation failed\n"
if got := diagnostics.String(); got != want {
t.Fatalf("diagnostics = %q, want %q", got, want)
}
if outcome.Presentation.Diagnostics.State != DeliveryComplete || outcome.Presentation.Output.State != DeliveryNone {
t.Fatalf("presentation = %#v", outcome.Presentation)
}
}
func TestReporterDeferredSubmissionCopiesDetailsAndRetainsDeferredFailures(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
reporter.setVisibility(modeVerbose, false, false)
writeFirst := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "first detail\n"); return err })
writeSecond := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "second detail\n"); return err })
details := []Detail{writeFirst}
deferred := reporter.AtEnd()
if err := deferred.Warn("queued", details...); err != nil {
t.Fatal(err)
}
details[0] = writeSecond
details = append(details, writeSecond)
outcome := reporter.finalize(Success(), nil)
if got, want := diagnostics.String(), "WARN: queued\nfirst detail\n"; got != want {
t.Fatalf("diagnostics = %q, want %q", got, want)
}
if outcome.Kind != OutcomeSuccess || outcome.Status != 0 || outcome.Presentation.Diagnostics.State != DeliveryComplete {
t.Fatalf("outcome = %#v", outcome)
}
renderErr := errors.New("deferred renderer failed")
failedReporter := newReporter(Streams{Err: io.Discard})
failedReporter.setVisibility(modeQuiet, false, false)
if err := failedReporter.AtEnd().Warn("hidden", func(io.Writer) error { return renderErr }); err != nil {
t.Fatal(err)
}
failed := failedReporter.finalize(Success(), nil)
if failed.Kind != OutcomeOperational || failed.Status != 1 || !errors.Is(failed.Presentation.Diagnostics.RenderError, renderErr) {
t.Fatalf("deferred renderer failure outcome = %#v", failed)
}
}
func TestReporterFinalizationFallbackAndCauseRetention(t *testing.T) {
var diagnostics bytes.Buffer
cause := errors.New("sensitive operation detail")
reporter := newReporter(Streams{Err: &diagnostics})
outcome := reporter.finalize(Failure("").WithCause(cause), nil)
if outcome.Explanation.Message != "Command failed." {
t.Fatalf("fallback explanation = %#v", outcome.Explanation)
}
if !errors.Is(outcome.Cause, cause) || !strings.Contains(diagnostics.String(), "ERROR: Command failed.\n") {
t.Fatalf("outcome=%#v output=%q", outcome, diagnostics.String())
}
if strings.Contains(diagnostics.String(), cause.Error()) {
t.Fatalf("cause was rendered automatically: %q", diagnostics.String())
}
var zeroDiagnostics bytes.Buffer
zeroReporter := newReporter(Streams{Err: &zeroDiagnostics})
zero := zeroReporter.finalize(Outcome{Cause: cause}, nil)
if zero.Kind != OutcomeSuccess || zero.Status != 0 || !errors.Is(zero.Cause, cause) || zeroDiagnostics.Len() != 0 {
t.Fatalf("zero-value outcome with cause changed classification: %#v output=%q", zero, zeroDiagnostics.String())
}
var native bytes.Buffer
nativeReporter := newReporter(Streams{Err: &native})
nativeOutcome := nativeReporter.finalize(Failure("", func(io.Writer) error { return nil }), nil)
if nativeOutcome.Explanation.Message != "" || native.String() != "" {
t.Fatalf("explicit zero-byte explanation changed: outcome=%#v output=%q", nativeOutcome, native.String())
}
}
func TestReporterPresentationFailureChangesOutcomeAndPreservesDomain(t *testing.T) {
outputErr := errors.New("report write failed")
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Out: &reporterTestWriter{err: outputErr}, Err: &diagnostics})
_, _ = reporter.Output().Write([]byte("partial"))
cause := errors.New("operation cause")
outcome := reporter.finalize(Success().WithCause(cause), nil)
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, cause) {
t.Fatalf("success presentation failure outcome = %#v", outcome)
}
if !errors.Is(outcome.Presentation.Output.WriteError, outputErr) || (!strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output")) {
t.Fatalf("presentation=%#v diagnostics=%q", outcome.Presentation, diagnostics.String())
}
for _, test := range []struct {
name string
outcome Outcome
wantKind OutcomeKind
wantStatus int
}{
{name: "usage becomes operational", outcome: Usage("invalid request").WithCause(cause), wantKind: OutcomeOperational, wantStatus: 1},
{name: "operational stays operational", outcome: Failure("operation failed").WithCause(cause), wantKind: OutcomeOperational, wantStatus: 1},
{name: "domain nonzero is retained", outcome: Domain("validation", 5).WithExplanation("rejected").WithCause(cause), wantKind: OutcomeDomain, wantStatus: 5},
{name: "domain zero becomes nonzero", outcome: Domain("validation", 0), wantKind: OutcomeDomain, wantStatus: 1},
} {
t.Run(test.name, func(t *testing.T) {
failing := newReporter(Streams{Out: &reporterTestWriter{err: outputErr}, Err: io.Discard})
_, _ = failing.Output().Write([]byte("partial"))
got := failing.finalize(test.outcome, nil)
if got.Kind != test.wantKind || got.Status != test.wantStatus {
t.Fatalf("outcome = %#v, want kind %q status %d", got, test.wantKind, test.wantStatus)
}
if got.Cause != nil && !errors.Is(got.Cause, cause) {
t.Fatalf("cause changed: %v", got.Cause)
}
})
}
}
func TestReporterDoesNotRetryFailedDiagnosticDestination(t *testing.T) {
diagnosticErr := errors.New("diagnostic sink failed")
destination := &reporterTestWriter{err: diagnosticErr}
reporter := newReporter(Streams{Err: destination})
_, _ = reporter.state.write(destinationDiagnostics, []byte("warning"))
outcome := reporter.finalize(Success(), nil)
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || destination.writes != 1 {
t.Fatalf("outcome=%#v diagnostic writes=%d", outcome, destination.writes)
}
if !errors.Is(outcome.Presentation.Diagnostics.WriteError, diagnosticErr) {
t.Fatalf("diagnostic delivery = %#v", outcome.Presentation.Diagnostics)
}
}
func TestReporterUsesHealthyDiagnosticsAfterOutputRenderFailure(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Out: io.Discard, Err: &diagnostics})
renderErr := errors.New("report preparation failed")
_ = reporter.RenderOutput(func(io.Writer) error { return renderErr })
outcome := reporter.finalize(Success(), nil)
if outcome.Kind != OutcomeOperational || outcome.Status != 1 {
t.Fatalf("outcome = %#v", outcome)
}
if !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || (!strings.Contains(diagnostics.String(), "render") || !strings.Contains(diagnostics.String(), "output")) {
t.Fatalf("presentation=%#v diagnostics=%q", outcome.Presentation, diagnostics.String())
}
}
func TestReporterTerminalDeliveryFailureIsRecordedWithoutRetry(t *testing.T) {
diagnosticErr := errors.New("terminal write failed")
destination := &reporterTestWriter{err: diagnosticErr}
reporter := newReporter(Streams{Err: destination})
outcome := reporter.finalize(Failure("explicit failure"), nil)
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || destination.writes != 1 {
t.Fatalf("outcome=%#v diagnostic writes=%d", outcome, destination.writes)
}
if !errors.Is(outcome.Presentation.Diagnostics.WriteError, diagnosticErr) {
t.Fatalf("terminal delivery = %#v", outcome.Presentation.Diagnostics)
}
}
func TestReporterTerminalRenderFailureIsRecordedWithoutRetry(t *testing.T) {
var diagnostics bytes.Buffer
reporter := newReporter(Streams{Err: &diagnostics})
renderErr := errors.New("terminal renderer failed")
calls := 0
outcome := reporter.finalize(Failure("", func(io.Writer) error {
calls++
return renderErr
}), nil)
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || calls != 1 || diagnostics.Len() != 0 {
t.Fatalf("outcome=%#v calls=%d output=%q", outcome, calls, diagnostics.String())
}
if !errors.Is(outcome.Presentation.Diagnostics.RenderError, renderErr) {
t.Fatalf("terminal presentation = %#v", outcome.Presentation.Diagnostics)
}
}
func TestReporterFinalizationClosesOutputAndDeferredHandles(t *testing.T) {
var output, diagnostics bytes.Buffer
reporter := newReporter(Streams{Out: &output, Err: &diagnostics})
writer := reporter.Output()
deferred := reporter.AtEnd()
_ = reporter.finalize(Success(), nil)
if n, err := writer.Write([]byte("late")); n != 0 || !errors.Is(err, ErrReporterClosed) {
t.Fatalf("late output = %d, %v", n, err)
}
if err := deferred.Warn("late warning"); !errors.Is(err, ErrReporterClosed) {
t.Fatalf("late deferred submission error = %v", err)
}
if output.Len() != 0 || diagnostics.Len() != 0 {
t.Fatalf("late output emitted: result=%q diagnostics=%q", output.String(), diagnostics.String())
}
}

103
route.go Normal file
View File

@@ -0,0 +1,103 @@
package commandkit
import (
"strings"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
type routeKind uint8
const (
routeUnknown routeKind = iota
routeCanonical
routeDiscovery
)
type routeResult struct {
kind routeKind
args []string
enteredPath commandpath.Path
canonicalPath commandpath.Path
discoveryPath commandpath.Path
}
func resolveEnteredRoute(args []string, snapshot *routingSnapshot) routeResult {
path, trailing := commandPathTokens(args)
result := routeResult{kind: routeUnknown}
if snapshot == nil {
result.args = append(append([]string(nil), path...), trailing...)
result.enteredPath = path.Clone()
return result
}
resolved, origins := snapshot.aliases.ResolveWithProvenance(path)
trace := traceCanonicalPath(snapshot.canonical, resolved)
switch {
case trace.exact != nil:
result.kind = routeCanonical
result.canonicalPath = trace.exact.path.Clone()
case len(path) == 0:
result.kind = routeCanonical
default:
discovery, ok := findDiscoveryPath(snapshot.discoveryFlags, resolved, path)
if !ok && len(snapshot.discoveryFlags) > 0 {
discovery, ok = findDiscoveryPath(snapshot.discoveryFlags, snapshot.aliases.ResolutionPaths(path)...)
}
if ok {
result.kind = routeDiscovery
result.discoveryPath = discovery
result.enteredPath = path.Clone()
result.args = commandpath.CloneTokens(trailing)
} else if trace.operandOwner != nil {
result.kind = routeCanonical
result.canonicalPath = trace.operandOwner.path.Clone()
} else {
result.enteredPath = path.Clone()
}
}
if result.kind == routeCanonical {
result.enteredPath = enteredEndpoint(path, origins, len(result.canonicalPath))
}
if result.kind != routeDiscovery {
result.args = make([]string, 0, len(resolved)+len(trailing))
result.args = append(result.args, resolved...)
result.args = append(result.args, trailing...)
}
return result
}
func commandPathTokens(args []string) (commandpath.Path, []string) {
end := len(args)
for index, token := range args {
if token == "--" || (token != "-" && strings.HasPrefix(token, "-")) {
end = index
break
}
}
path := make(commandpath.Path, end)
copy(path, args[:end])
return path, append([]string(nil), args[end:]...)
}
func enteredEndpoint(path commandpath.Path, origins [][]int, canonicalLength int) commandpath.Path {
inputLength := 0
for _, componentOrigins := range origins[:canonicalLength] {
for _, origin := range componentOrigins {
if origin+1 > inputLength {
inputLength = origin + 1
}
}
}
return path[:inputLength].Clone()
}
func findDiscoveryPath(contexts map[string]resolvedFlagAliases, paths ...commandpath.Path) (commandpath.Path, bool) {
for _, path := range paths {
if _, exists := contexts[path.Key()]; len(path) > 0 && exists {
return path.Clone(), true
}
}
return nil, false
}

77
route_operands_test.go Normal file
View File

@@ -0,0 +1,77 @@
package commandkit
import (
"bytes"
"context"
"io"
"reflect"
"strings"
"testing"
)
func TestRunnableParentKeepsNonCommandTokensAsOperands(t *testing.T) {
type input struct {
words []string
format Supplied[string]
}
var received input
var entered, canonical string
built, parentRuns, childRuns := 0, 0, 0
child := NewCommand(CommandSpec[struct{}]{Name: "child", Run: func(inv Invocation, _ struct{}) Outcome {
childRuns++
entered, canonical = inv.EnteredPath(), inv.CanonicalPath()
return Success()
}})
parent := NewCommand(CommandSpec[input]{
Name: "run", Children: []Command{child}, NewInput: func() input { built++; return input{} },
Arguments: []Argument[input]{RepeatArgument(ArgumentMeta{Name: "ARG"}, 0, -1,
func(value string) (string, error) { return value, nil },
func(target *input, value Supplied[[]string]) { target.words = value.Value })},
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "format", Short: "f"}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(target *input, value Supplied[string]) { target.format = value })},
Run: func(inv Invocation, value input) Outcome {
parentRuns++
received, entered, canonical = value, inv.EnteredPath(), inv.CanonicalPath()
return Success()
},
})
app, err := New(Identity{Name: "Demo"}, parent, Alias("r", "run"), FlagAliasFor("run", "--fmt", "--format"))
if err != nil {
t.Fatal(err)
}
for _, spelling := range []string{"run", "r"} {
for _, operand := range []string{"", "-"} {
t.Run(spelling+"/"+operand, func(t *testing.T) {
beforeBuilt, beforeRuns := built, parentRuns
args := []string{spelling, operand, "child", "--fmt=yaml"}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || built != beforeBuilt+1 || parentRuns != beforeRuns+1 || childRuns != 0 {
t.Fatalf("outcome = %#v, builds/runs = %d/%d/%d, diagnostics %q", outcome, built, parentRuns, childRuns, diagnostics.String())
}
if !reflect.DeepEqual(received.words, []string{operand, "child"}) || received.format != (Supplied[string]{Value: "yaml", Set: true}) ||
entered != spelling || canonical != "run" {
t.Fatalf("parent input = %#v, paths = %q/%q", received, entered, canonical)
}
outcome = app.Execute(context.Background(), append(args, "--help"), Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || built != beforeBuilt+1 || parentRuns != beforeRuns+1 ||
!strings.Contains(output.String(), "Demo "+spelling+" [ARG...]") || !strings.Contains(output.String(), "--fmt → --format") {
t.Fatalf("parent help = %#v, output %q, diagnostics %q", outcome, output.String(), diagnostics.String())
}
outcome = app.Execute(context.Background(), []string{spelling, "--", operand, "child", "--fmt=yaml"}, Streams{Out: io.Discard, Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || !reflect.DeepEqual(received.words, []string{operand, "child", "--fmt=yaml"}) || received.format.Set {
t.Fatalf("terminated parent invocation = %#v, input %#v, diagnostics %q", outcome, received, diagnostics.String())
}
})
}
}
for _, spelling := range []string{"run", "r"} {
beforeBuilt, beforeRuns, beforeChildren := built, parentRuns, childRuns
outcome := app.Execute(context.Background(), []string{spelling, "child"}, Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != OutcomeSuccess || built != beforeBuilt || parentRuns != beforeRuns || childRuns != beforeChildren+1 ||
entered != spelling+" child" || canonical != "run child" {
t.Fatalf("genuine child selection = %#v, paths %q/%q, builds/runs %d/%d/%d", outcome, entered, canonical, built, parentRuns, childRuns)
}
}
}

1029
route_test.go Normal file

File diff suppressed because it is too large Load Diff