Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 230187296f | |||
| 473fb027a1 | |||
| 135d8da2b9 | |||
| de2266af75 | |||
| 13a4d4eacd | |||
| 215fc34d51 | |||
| 3338b902a2 | |||
| 41c6ca8070 | |||
| ee76b0f5f1 | |||
| 1e532cdfa5 | |||
| 7c035ccc54 | |||
| 96aa6b5ca0 | |||
| a6c0fb5b0d | |||
| 6264a87183 | |||
| b6f22462d3 | |||
| 401232a1b0 | |||
| 4ace616bc2 | |||
| 7c010e945a | |||
| 7fc89768ef | |||
| 37dfea5b25 | |||
| c0e0010683 | |||
| 5f37aac70f | |||
| 9a4d1db955 | |||
| f3e9326544 | |||
| 7a1067d387 | |||
| 49a10690c6 | |||
| df21b75f9c | |||
| f3bd6dc2ae | |||
| 6ba9338c0a | |||
| 5df88a6d94 | |||
| f5a5bbac58 | |||
| e56537690b | |||
| 2a1096bd1f | |||
| 1d29cdd029 | |||
| e48187d822 | |||
| e951a48902 | |||
| 976ed89403 | |||
| a5a92c7f08 | |||
| 514c4d47bb | |||
| ec29d658b7 | |||
| 085c296d89 | |||
| 0869b17b43 | |||
| 810333e376 | |||
| 2e337155ce | |||
| fa0aeace36 | |||
| 09d4d7992a | |||
| c70e29d3fd | |||
| e47ae799d7 | |||
| 5c26d80228 | |||
| bafd365115 | |||
| 4ddf960836 | |||
| 08ad7427e2 | |||
| f42aefe02d | |||
| 520d7e125b | |||
| eb92f7be7f | |||
| 744b8135f2 | |||
| c231a41665 | |||
| fa09edf8cc | |||
| 7dad20680e | |||
| ccf8acd861 | |||
| c225a66c97 | |||
| a54a9354b1 | |||
| 42d9989533 | |||
| 333a630cf1 | |||
| 666c006a89 | |||
| c3bb3941d3 | |||
| f34ab12ca5 | |||
| 1fa026e5bd | |||
| a504efbad7 | |||
| 451a0c483f | |||
| 75fc45f072 | |||
| 58b71c390f | |||
| 6ea8c4dd3f | |||
| 2ab87e9333 | |||
| 7a4efddab8 | |||
| 854426a0c9 | |||
| 80d4d6e1bd | |||
| b3fb795224 | |||
| d7cc12a347 | |||
| f3b8a129d9 | |||
| fae8c4dafa | |||
| ca85d5b012 | |||
| 93ee328779 | |||
| c6c4b01902 | |||
| 55330d2817 | |||
| fbef0f2ecc | |||
| 573a22a38e | |||
| f526198059 | |||
| 4b53c96cf3 | |||
| 50c54c229e | |||
| 0dfeac3022 | |||
| 8d5966a868 | |||
| a4cf55f7a5 | |||
| 1f3d1594c9 | |||
| ae6af1dc65 | |||
| df6ed89a97 | |||
| a320c76574 |
10
README.md
10
README.md
@@ -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
42
app.go
@@ -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
|
||||
}
|
||||
|
||||
56
binding.go
56
binding.go
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
86
builtins.go
86
builtins.go
@@ -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
110
builtins_reporting_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
190
command.go
190
command.go
@@ -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
118
debug_events_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
241
declaration_external_test.go
Normal file
241
declaration_external_test.go
Normal 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
7
doc.go
@@ -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
|
||||
|
||||
68
docs/adr/0013-application-alias-routing.md
Normal file
68
docs/adr/0013-application-alias-routing.md
Normal 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.
|
||||
79
docs/adr/0014-invocation-reporting.md
Normal file
79
docs/adr/0014-invocation-reporting.md
Normal 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.
|
||||
180
docs/cli.md
180
docs/cli.md
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
74
docs/releases/v0.3.0.md
Normal 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)
|
||||
820
docs/roadmap/implementation.md
Normal file
820
docs/roadmap/implementation.md
Normal 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
523
docs/roadmap/remediation.md
Normal 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
890
docs/roadmap/reporting.md
Normal 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
31
examples/core/README.md
Normal 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.
|
||||
@@ -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
199
examples/core/main_test.go
Normal 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()
|
||||
}
|
||||
396
execute.go
396
execute.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
189
execute_reporting_test.go
Normal 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
282
flag_aliases.go
Normal 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
297
flag_aliases_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
63
help_view.go
Normal 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
|
||||
}
|
||||
45
identity.go
45
identity.go
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
schema:
|
||||
version: 2
|
||||
greeting: Goodbye
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
schema:
|
||||
version: 1
|
||||
greeting: Hello
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
69
integrations/ckconfig/report_cancellation_test.go
Normal file
69
integrations/ckconfig/report_cancellation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
80
integrations/ckprompt/native_explanation_test.go
Normal file
80
integrations/ckprompt/native_explanation_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
50
integrations/ckprompt/report_cancellation_test.go
Normal file
50
integrations/ckprompt/report_cancellation_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ©
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
148
internal/alias/machine.go
Normal 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
|
||||
}
|
||||
}
|
||||
28
internal/alias/provenance_test.go
Normal file
28
internal/alias/provenance_test.go
Normal 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
184
internal/alias/resolver.go
Normal 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
|
||||
}
|
||||
}
|
||||
316
internal/alias/resolver_test.go
Normal file
316
internal/alias/resolver_test.go
Normal 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}
|
||||
}
|
||||
262
internal/alias/termination.go
Normal file
262
internal/alias/termination.go
Normal 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
136
internal/cobra/flags.go
Normal 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
|
||||
}
|
||||
294
internal/cobra/flags_test.go
Normal file
294
internal/cobra/flags_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}}},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
28
internal/commandmeta/metadata_test.go
Normal file
28
internal/commandmeta/metadata_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
106
internal/commandpath/path.go
Normal file
106
internal/commandpath/path.go
Normal 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, " ")
|
||||
}
|
||||
80
internal/commandpath/path_test.go
Normal file
80
internal/commandpath/path_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
181
outcome.go
181
outcome.go
@@ -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,
|
||||
|
||||
129
outcome_test.go
129
outcome_test.go
@@ -2,39 +2,130 @@ package commandkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOutcomeConstructors(t *testing.T) {
|
||||
cause := errors.New("operation failed")
|
||||
type outcomeTestCause struct{ message string }
|
||||
|
||||
func (e *outcomeTestCause) Error() string { return e.message }
|
||||
|
||||
func TestOutcomeConstructorsAndModifiers(t *testing.T) {
|
||||
var calls int
|
||||
detail := Detail(func(io.Writer) error {
|
||||
calls++
|
||||
return nil
|
||||
})
|
||||
cause := &outcomeTestCause{message: "operation failed"}
|
||||
wrapped := errors.Join(errors.New("context"), cause)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
got Outcome
|
||||
kind OutcomeKind
|
||||
status int
|
||||
domain string
|
||||
wantCause error
|
||||
name string
|
||||
got Outcome
|
||||
kind OutcomeKind
|
||||
status int
|
||||
domain string
|
||||
}{
|
||||
{name: "success", got: Success(), kind: OutcomeSuccess, status: 0},
|
||||
{name: "usage", got: Usage(cause), kind: OutcomeUsage, status: 2, wantCause: cause},
|
||||
{name: "failure", got: Failure(cause), kind: OutcomeOperational, status: 1, wantCause: cause},
|
||||
{name: "domain", got: Domain("record", 7, cause), kind: OutcomeDomain, status: 7, domain: "record", wantCause: cause},
|
||||
{name: "usage", got: Usage("invalid input", detail), kind: OutcomeUsage, status: 2},
|
||||
{name: "failure", got: Failure("operation failed", detail), kind: OutcomeOperational, status: 1},
|
||||
{name: "domain", got: Domain("record", 7), kind: OutcomeDomain, status: 7, domain: "record"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if test.got.Kind != test.kind {
|
||||
t.Fatalf("Kind = %q, want %q", test.got.Kind, test.kind)
|
||||
if test.got.Kind != test.kind || test.got.Status != test.status || test.got.Domain != test.domain {
|
||||
t.Fatalf("outcome = %#v, want kind %q, status %d, domain %q", test.got, test.kind, test.status, test.domain)
|
||||
}
|
||||
if test.got.Status != test.status {
|
||||
t.Fatalf("Status = %d, want %d", test.got.Status, test.status)
|
||||
})
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("constructors called detail renderer %d times", calls)
|
||||
}
|
||||
|
||||
base := Domain("validation", 3).WithCause(wrapped)
|
||||
updated := base.WithExplanation("invalid record", detail)
|
||||
if updated.Kind != base.Kind || updated.Status != base.Status || updated.Domain != base.Domain {
|
||||
t.Fatalf("WithExplanation changed classification: base %#v, updated %#v", base, updated)
|
||||
}
|
||||
if !errors.Is(updated.Cause, cause) {
|
||||
t.Fatalf("WithExplanation lost cause identity: %v", updated.Cause)
|
||||
}
|
||||
var gotCause *outcomeTestCause
|
||||
if !errors.As(updated.Cause, &gotCause) || gotCause != cause {
|
||||
t.Fatalf("WithExplanation lost typed cause: %#v", gotCause)
|
||||
}
|
||||
if updated.Explanation.Message != "invalid record" || len(updated.Explanation.Details) != 1 {
|
||||
t.Fatalf("WithExplanation = %#v", updated.Explanation)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("WithExplanation called detail renderer %d times", calls)
|
||||
}
|
||||
|
||||
changedCause := updated.WithCause(errors.New("replacement"))
|
||||
if changedCause.Kind != updated.Kind || changedCause.Status != updated.Status || changedCause.Domain != updated.Domain {
|
||||
t.Fatalf("WithCause changed classification: %#v", changedCause)
|
||||
}
|
||||
if changedCause.Explanation.Message != updated.Explanation.Message || len(changedCause.Explanation.Details) != 1 {
|
||||
t.Fatalf("WithCause changed explanation: %#v", changedCause.Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutcomeCopiesDetailSlices(t *testing.T) {
|
||||
one := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "one"); return err })
|
||||
two := Detail(func(w io.Writer) error { _, err := io.WriteString(w, "two"); return err })
|
||||
tests := []struct {
|
||||
name string
|
||||
make func([]Detail) Outcome
|
||||
}{
|
||||
{name: "failure", make: func(details []Detail) Outcome { return Failure("failed", details...) }},
|
||||
{name: "usage", make: func(details []Detail) Outcome { return Usage("invalid", details...) }},
|
||||
{name: "modifier", make: func(details []Detail) Outcome { return Success().WithExplanation("failed", details...) }},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
supplied := []Detail{one}
|
||||
outcome := test.make(supplied)
|
||||
supplied[0] = two
|
||||
supplied = append(supplied, two)
|
||||
if len(outcome.Explanation.Details) != 1 || outcome.Explanation.Details[0] == nil {
|
||||
t.Fatalf("captured details = %#v", outcome.Explanation.Details)
|
||||
}
|
||||
if test.got.Cause != test.wantCause {
|
||||
t.Fatalf("Cause = %v, want original cause %v", test.got.Cause, test.wantCause)
|
||||
var rendered strings.Builder
|
||||
if err := outcome.Explanation.Details[0](&rendered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.got.Domain != test.domain {
|
||||
t.Fatalf("Domain = %q, want %q", test.got.Domain, test.domain)
|
||||
if rendered.String() != "one" {
|
||||
t.Fatalf("captured callback rendered %q, want original callback", rendered.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCausePreservesErrorIdentity(t *testing.T) {
|
||||
cause := &outcomeTestCause{message: "typed failure"}
|
||||
outcome := Failure("failed").WithCause(cause)
|
||||
if !errors.Is(outcome.Cause, cause) {
|
||||
t.Fatalf("errors.Is(%v, cause) = false", outcome.Cause)
|
||||
}
|
||||
var got *outcomeTestCause
|
||||
if !errors.As(outcome.Cause, &got) || got != cause {
|
||||
t.Fatalf("errors.As cause = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageDiagnosticIsPassiveData(t *testing.T) {
|
||||
diagnostic := MessageDiagnostic(SeverityWarning, "review needed\n")
|
||||
if diagnostic.Severity != SeverityWarning || diagnostic.Write == nil {
|
||||
t.Fatalf("diagnostic = %#v", diagnostic)
|
||||
}
|
||||
var rendered strings.Builder
|
||||
if err := diagnostic.Write(&rendered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rendered.String(); got != "review needed\n" {
|
||||
t.Fatalf("rendered diagnostic = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
69
parser.go
69
parser.go
@@ -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
126
parser_reporting_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
352
registry.go
352
registry.go
@@ -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
|
||||
}
|
||||
|
||||
263
registry_test.go
263
registry_test.go
@@ -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(®isteredCommand, ®isteredRule); 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
674
reporter.go
Normal 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
69
reporter_deferred_test.go
Normal 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
115
reporter_error_test.go
Normal 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
697
reporter_test.go
Normal 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
103
route.go
Normal 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
77
route_operands_test.go
Normal 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
1029
route_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user