Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e256eb1cc0 | |||
| 881dae27c4 | |||
| c8d659dfe5 | |||
| d7cbaed3ed | |||
| 289a171e97 | |||
| 2cde616ce5 | |||
| 42d1774019 | |||
| bdc3fd8fa3 | |||
| eb974b6d29 | |||
| 0dc4ac41f0 | |||
| 129f95be0f | |||
| 87eb84ccb4 | |||
| 0fc00039bd | |||
| 4d92ee4ee6 | |||
| 7eac3fea65 | |||
| 12a6fa66fa | |||
| 98e7e99333 | |||
| 124a9fa57f | |||
| 75c0d5bce2 | |||
| 007eae4ed0 | |||
| 48fd2dc7bc | |||
| b94ed6665b | |||
| 7344994198 | |||
| cb40f1d9f8 | |||
| 3d396b1a66 | |||
| 09bb51d1c3 | |||
| a74fcb2004 | |||
| 3934038b98 | |||
| 0acbbf45ad | |||
| f4ed4c4839 | |||
| 5227049de6 | |||
| 8964ad02ad | |||
| f79f446726 | |||
| b83c0a0dfc | |||
| f39f27b5cd | |||
| 8b48115328 | |||
| c205903900 | |||
| c87bba3d2e | |||
| d3d40acea0 | |||
| 0450ecfc16 | |||
| 96250d1887 | |||
| d1f31d2931 | |||
| 4c47a0ca0b | |||
| 8bb5383169 | |||
| fc2f6d5346 | |||
| 0bed76a6f7 | |||
| 1d193c87fe | |||
| 10a799a2b7 | |||
| 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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -31,7 +31,7 @@ go.work.sum
|
|||||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||||
.fuse_hidden*
|
.fuse_hidden*
|
||||||
|
|
||||||
# Local ReleaseKit bootstrap executable
|
# Local ReleaseKit credentials
|
||||||
/.releasekit/
|
/.releasekit/
|
||||||
|
|
||||||
# KDE directory preferences
|
# KDE directory preferences
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -8,24 +8,29 @@ modules, so applications choose which upstream libraries they add.
|
|||||||
The core captures reusable command declarations, validates installation batches
|
The core captures reusable command declarations, validates installation batches
|
||||||
before changing an application's registry, converts typed inputs before
|
before changing an application's registry, converts typed inputs before
|
||||||
constructing them, and returns outcomes without exiting the process. Its
|
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
|
## Modules
|
||||||
|
|
||||||
| Module | Package | Provides |
|
| Module | Package | Provides |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `gitea.maximumdirect.net/eric/CommandKit` | `commandkit` | Typed commands, bindings, execution, outcomes, help, and version. |
|
| `go.maximumdirect.net/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/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. |
|
| `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. |
|
||||||
|
|
||||||
Importing an integration does not install commands. Pass its declaration to
|
Importing an integration does not install commands. The integrations currently
|
||||||
`commandkit.New` or `App.Register` to install it. The integrations depend on the
|
use the old core module identity and cannot be composed with the new core path.
|
||||||
core and their selected upstream libraries; the core does not depend on either
|
The planned matched releases are `go.maximumdirect.net/ckconfig v0.2.0` and
|
||||||
integration.
|
`go.maximumdirect.net/ckprompt v0.2.0`, using core v0.3.0. See the
|
||||||
|
[consumer guide](docs/consumers/guide.md#module-selection) for migration details.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
- [Core-only example](examples/core/main.go)
|
- [Core-only example](examples/core/README.md)
|
||||||
- [ConfigKit example](integrations/ckconfig/examples/simple/README.md)
|
- [ConfigKit example](integrations/ckconfig/examples/simple/README.md)
|
||||||
- [Composed ConfigKit and PromptKit example](integrations/ckprompt/examples/composed/README.md)
|
- [Composed ConfigKit and PromptKit example](integrations/ckprompt/examples/composed/README.md)
|
||||||
|
|
||||||
|
|||||||
35
app.go
35
app.go
@@ -3,7 +3,8 @@ package commandkit
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
"go.maximumdirect.net/commandkit/internal/alias"
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App owns one command registry and its application identity. Each App has an
|
// App owns one command registry and its application identity. Each App has an
|
||||||
@@ -11,30 +12,50 @@ import (
|
|||||||
type App struct {
|
type App struct {
|
||||||
identity Identity
|
identity Identity
|
||||||
commands map[string]*commandNode
|
commands map[string]*commandNode
|
||||||
|
routingRules []RoutingRule
|
||||||
|
aliases *alias.Resolver
|
||||||
|
flagAliases map[string]resolvedFlagAliases
|
||||||
|
discoveryFlags map[string]resolvedFlagAliases
|
||||||
snapshot *routingSnapshot
|
snapshot *routingSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
type routingSnapshot struct {
|
type routingSnapshot struct {
|
||||||
metadata *commandmeta.Command
|
metadata *commandmeta.Command
|
||||||
runners map[commandmeta.Key]commandPayload
|
runners map[commandmeta.Key]commandPayload
|
||||||
|
aliases *alias.Resolver
|
||||||
|
canonical map[string]*canonicalNode
|
||||||
|
commandAliases []commandmeta.CommandAlias
|
||||||
|
flagAliases map[string]resolvedFlagAliases
|
||||||
|
discoveryFlags map[string]resolvedFlagAliases
|
||||||
|
completion bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type commandNode struct {
|
type commandNode struct {
|
||||||
command Command
|
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
|
// The identity name must not be empty. New installs its generated help and
|
||||||
// version commands and caller declarations as one batch; an invalid declaration
|
// version commands and caller declarations as one batch; an invalid, nil, or
|
||||||
// returns an error without exposing an App. The root help and version names are
|
// zero declaration returns an error without exposing an App. Factory issues
|
||||||
// reserved for generated commands.
|
// retained in Command and RoutingRule values are reported during installation.
|
||||||
func New(identity Identity, commands ...Command) (*App, error) {
|
// 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 == "" {
|
if identity.Name == "" {
|
||||||
return nil, fmt.Errorf("application identity name must not be empty")
|
return nil, fmt.Errorf("application identity name must not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
app := &App{identity: identity, commands: make(map[string]*commandNode)}
|
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 {
|
if err := app.installBatch(all...); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
118
binding.go
118
binding.go
@@ -1,8 +1,11 @@
|
|||||||
package commandkit
|
package commandkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValueFlag declares a singleton flag with a typed parser, optional default,
|
// ValueFlag declares a singleton flag with a typed parser, optional default,
|
||||||
@@ -22,10 +25,10 @@ func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V,
|
|||||||
binding.prepare = func(values []string) (preparedValue[I], error) {
|
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||||
value := fallback
|
value := fallback
|
||||||
supplied := len(values) > 0
|
supplied := len(values) > 0
|
||||||
for _, raw := range values {
|
for index, raw := range values {
|
||||||
parsed, err := parse(raw)
|
parsed, err := parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedValue[I]{}, err
|
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||||
}
|
}
|
||||||
value = parsed
|
value = parsed
|
||||||
}
|
}
|
||||||
@@ -47,10 +50,10 @@ func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[boo
|
|||||||
binding.prepare = func(values []string) (preparedValue[I], error) {
|
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||||
value := defaultValue
|
value := defaultValue
|
||||||
supplied := len(values) > 0
|
supplied := len(values) > 0
|
||||||
for _, raw := range values {
|
for index, raw := range values {
|
||||||
parsed, err := strconv.ParseBool(raw)
|
parsed, err := strconv.ParseBool(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedValue[I]{}, err
|
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||||
}
|
}
|
||||||
value = parsed
|
value = parsed
|
||||||
}
|
}
|
||||||
@@ -76,10 +79,10 @@ func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, er
|
|||||||
supplied := len(values) > 0
|
supplied := len(values) > 0
|
||||||
if supplied {
|
if supplied {
|
||||||
parsedValues = make([]V, 0, len(values))
|
parsedValues = make([]V, 0, len(values))
|
||||||
for _, raw := range values {
|
for index, raw := range values {
|
||||||
parsed, err := parse(raw)
|
parsed, err := parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedValue[I]{}, err
|
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||||
}
|
}
|
||||||
parsedValues = append(parsedValues, parsed)
|
parsedValues = append(parsedValues, parsed)
|
||||||
}
|
}
|
||||||
@@ -158,6 +161,28 @@ type preparedInput[I any] struct {
|
|||||||
values []preparedValue[I]
|
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 {
|
func (p preparedInput[I]) build() I {
|
||||||
var input I
|
var input I
|
||||||
if p.newInput != nil {
|
if p.newInput != nil {
|
||||||
@@ -173,15 +198,36 @@ func (p preparedInput[I]) build() I {
|
|||||||
// creates an input value. When enforceRequired is false, missing required flags
|
// creates an input value. When enforceRequired is false, missing required flags
|
||||||
// and operands are allowed so metadata help can validate supplied syntax.
|
// 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) {
|
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) {
|
if len(flagValues) != len(spec.Flags) {
|
||||||
return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", 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))
|
preparedFlags := make([]preparedValue[I], len(spec.Flags))
|
||||||
missingRequired := ""
|
missingRequired := ""
|
||||||
for i, flag := range spec.Flags {
|
for i, flag := range spec.Flags {
|
||||||
binding := flag.binding
|
binding := flag.binding
|
||||||
value, err := binding.prepare(flagValues[i])
|
value, err := binding.prepare(flagValues[i])
|
||||||
if err != nil {
|
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)
|
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
|
||||||
}
|
}
|
||||||
preparedFlags[i] = value
|
preparedFlags[i] = value
|
||||||
@@ -211,60 +257,76 @@ func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []
|
|||||||
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
|
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
|
||||||
|
|
||||||
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
|
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
|
||||||
|
arguments := make([]commandmeta.Argument, len(definitions))
|
||||||
|
for i, definition := range definitions {
|
||||||
|
arguments[i] = commandmeta.Argument{Name: definition.binding.meta.Name, Min: definition.binding.min, Max: definition.binding.max}
|
||||||
|
}
|
||||||
|
counts, err := allocateOperandCounts(len(operands), arguments, enforceRequired)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allocated := make([][]string, len(definitions))
|
||||||
|
position := 0
|
||||||
|
for i, count := range counts {
|
||||||
|
allocated[i] = operands[position : position+count]
|
||||||
|
position += count
|
||||||
|
}
|
||||||
|
return allocated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func allocateOperandCounts(total int, definitions []commandmeta.Argument, enforceRequired bool) ([]int, error) {
|
||||||
for _, argument := range definitions {
|
for _, argument := range definitions {
|
||||||
binding := argument.binding
|
if argument.Min < 0 || argument.Max < -1 || (argument.Max >= 0 && argument.Min > argument.Max) {
|
||||||
if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) {
|
return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", argument.Name, argument.Min, argument.Max)
|
||||||
return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if enforceRequired {
|
if enforceRequired {
|
||||||
minimum := 0
|
minimum := 0
|
||||||
for _, argument := range definitions {
|
for _, argument := range definitions {
|
||||||
if argument.binding.min > len(operands)-minimum {
|
if argument.Min > total-minimum {
|
||||||
return nil, fmt.Errorf("not enough positional arguments")
|
return nil, fmt.Errorf("not enough positional arguments")
|
||||||
}
|
}
|
||||||
minimum += argument.binding.min
|
minimum += argument.Min
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suffixMinimum := make([]int, len(definitions)+1)
|
suffixMinimum := make([]int, len(definitions)+1)
|
||||||
for i := len(definitions) - 1; i >= 0; i-- {
|
for i := len(definitions) - 1; i >= 0; i-- {
|
||||||
minimum := definitions[i].binding.min
|
minimum := definitions[i].Min
|
||||||
remaining := len(operands) - suffixMinimum[i+1]
|
remaining := total - suffixMinimum[i+1]
|
||||||
if minimum > remaining {
|
if minimum > remaining {
|
||||||
suffixMinimum[i] = len(operands)
|
suffixMinimum[i] = total
|
||||||
} else {
|
} else {
|
||||||
suffixMinimum[i] = suffixMinimum[i+1] + minimum
|
suffixMinimum[i] = suffixMinimum[i+1] + minimum
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allocated := make([][]string, len(definitions))
|
counts := make([]int, len(definitions))
|
||||||
position := 0
|
position := 0
|
||||||
for i, argument := range definitions {
|
for i, argument := range definitions {
|
||||||
binding := argument.binding
|
remaining := total - position
|
||||||
remaining := len(operands) - position
|
|
||||||
available := remaining - suffixMinimum[i+1]
|
available := remaining - suffixMinimum[i+1]
|
||||||
if available < 0 {
|
if available < 0 {
|
||||||
available = 0
|
available = 0
|
||||||
}
|
}
|
||||||
count := available
|
count := available
|
||||||
if binding.max >= 0 && count > binding.max {
|
if argument.Max >= 0 && count > argument.Max {
|
||||||
count = binding.max
|
count = argument.Max
|
||||||
}
|
}
|
||||||
if !enforceRequired && count < binding.min && remaining > 0 {
|
if !enforceRequired && count < argument.Min && remaining > 0 {
|
||||||
count = remaining
|
count = remaining
|
||||||
if binding.max >= 0 && count > binding.max {
|
if argument.Max >= 0 && count > argument.Max {
|
||||||
count = binding.max
|
count = argument.Max
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if enforceRequired && count < binding.min {
|
if enforceRequired && count < argument.Min {
|
||||||
return nil, fmt.Errorf("not enough values for argument %q", binding.meta.Name)
|
return nil, fmt.Errorf("not enough values for argument %q", argument.Name)
|
||||||
}
|
}
|
||||||
allocated[i] = operands[position : position+count]
|
counts[i] = count
|
||||||
position += count
|
position += count
|
||||||
}
|
}
|
||||||
if position < len(operands) {
|
if position < total {
|
||||||
return nil, fmt.Errorf("received %d extra positional arguments", len(operands)-position)
|
return nil, fmt.Errorf("received %d extra positional arguments", total-position)
|
||||||
}
|
}
|
||||||
return allocated, nil
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package commandkit
|
package commandkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -245,3 +247,90 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
|
|||||||
t.Fatalf("preparation invoked NewInput %d times", factoryCalls)
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
94
builtins.go
94
builtins.go
@@ -1,18 +1,16 @@
|
|||||||
package commandkit
|
package commandkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
|
||||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
const commandKitModulePath = "gitea.maximumdirect.net/eric/CommandKit"
|
const commandKitModulePath = "go.maximumdirect.net/commandkit"
|
||||||
|
|
||||||
type helpInput struct {
|
type helpInput struct {
|
||||||
Targets Supplied[[]string]
|
Targets Supplied[[]string]
|
||||||
@@ -29,7 +27,7 @@ type versionDocument struct {
|
|||||||
Revision string `yaml:"revision"`
|
Revision string `yaml:"revision"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) builtinCommands() []Command {
|
func (a *App) builtinCommands() []Declaration {
|
||||||
help := captureCommand(CommandSpec[helpInput]{
|
help := captureCommand(CommandSpec[helpInput]{
|
||||||
Name: "help",
|
Name: "help",
|
||||||
Summary: "Show help for commands.",
|
Summary: "Show help for commands.",
|
||||||
@@ -47,8 +45,8 @@ func (a *App) builtinCommands() []Command {
|
|||||||
Summary: "Show application or CommandKit version.",
|
Summary: "Show application or CommandKit version.",
|
||||||
Description: "Report identity and build metadata without loading application configuration.",
|
Description: "Report identity and build metadata without loading application configuration.",
|
||||||
Flags: []Flag[versionInput]{
|
Flags: []Flag[versionInput]{
|
||||||
ValueFlag(FlagMeta{Name: "scope", Usage: "version subject"}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }),
|
ValueFlag(FlagMeta{Name: "scope", Usage: "version subject", Completion: CompleteChoices(CompletionChoice{Value: "app"}, CompletionChoice{Value: "commandkit"})}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }),
|
||||||
ValueFlag(FlagMeta{Name: "format", Usage: "output format"}, &formatDefault, parseVersionFormat, func(input *versionInput, value Supplied[string]) { input.Format = value.Value }),
|
ValueFlag(FlagMeta{Name: "format", Usage: "output format", Completion: CompleteChoices(CompletionChoice{Value: "text"}, CompletionChoice{Value: "yaml"})}, &formatDefault, parseVersionFormat, func(input *versionInput, value Supplied[string]) { input.Format = value.Value }),
|
||||||
},
|
},
|
||||||
Run: func(invocation Invocation, input versionInput) Outcome {
|
Run: func(invocation Invocation, input versionInput) Outcome {
|
||||||
identity := a.identity
|
identity := a.identity
|
||||||
@@ -58,10 +56,10 @@ func (a *App) builtinCommands() []Command {
|
|||||||
if identity.Version == "" {
|
if identity.Version == "" {
|
||||||
identity.Version = "dev"
|
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) {
|
func parseVersionScope(value string) (string, error) {
|
||||||
@@ -78,61 +76,41 @@ func parseVersionFormat(value string) (string, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) renderHelpOutcome(path []string, streams Streams) Outcome {
|
func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, reporter *Reporter, session *internalcobra.Session) Outcome {
|
||||||
session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{})
|
content, failure := session.RenderHelpView(view)
|
||||||
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)
|
|
||||||
if failure != nil {
|
if failure != nil {
|
||||||
if failure.Usage {
|
if failure.Usage {
|
||||||
return Usage(errors.New(failure.Message))
|
return Usage(failure.Message)
|
||||||
}
|
}
|
||||||
return Failure(errors.New(failure.Message))
|
return Failure(failure.Message)
|
||||||
}
|
|
||||||
if err := writeReport(streams.Out, []byte(content)); err != nil {
|
|
||||||
return incompleteReport(err)
|
|
||||||
}
|
}
|
||||||
|
_ = reporter.RenderOutput(func(w io.Writer) error {
|
||||||
|
_, err := io.WriteString(w, content)
|
||||||
|
return err
|
||||||
|
})
|
||||||
return Success()
|
return Success()
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderVersionOutcome(identity Identity, format string, streams Streams) Outcome {
|
func renderVersionOutcome(identity Identity, format string, reporter *Reporter) Outcome {
|
||||||
var content []byte
|
|
||||||
switch format {
|
|
||||||
case "text":
|
|
||||||
name := identity.Name
|
|
||||||
if identity.Version == "" {
|
if identity.Version == "" {
|
||||||
identity.Version = "dev"
|
identity.Version = "dev"
|
||||||
}
|
}
|
||||||
line := name + " " + identity.Version
|
switch format {
|
||||||
|
case "text":
|
||||||
|
line := identity.Name + " " + identity.Version
|
||||||
if identity.Revision != "" {
|
if identity.Revision != "" {
|
||||||
line += " (" + 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":
|
case "yaml":
|
||||||
if identity.Version == "" {
|
_ = reporter.RenderOutput(func(w io.Writer) error {
|
||||||
identity.Version = "dev"
|
return encodeYAML(w, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision})
|
||||||
}
|
})
|
||||||
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()
|
|
||||||
default:
|
default:
|
||||||
return Usage(fmt.Errorf("unsupported version format %q", format))
|
return Usage(fmt.Sprintf("unsupported version format %q", format))
|
||||||
}
|
|
||||||
if err := writeReport(streams.Out, content); err != nil {
|
|
||||||
return incompleteReport(err)
|
|
||||||
}
|
}
|
||||||
return Success()
|
return Success()
|
||||||
}
|
}
|
||||||
@@ -143,22 +121,6 @@ func encodeYAML(writer io.Writer, document any) error {
|
|||||||
return encoder.Encode(document)
|
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 {
|
func readBuildInfo() *debug.BuildInfo {
|
||||||
info, ok := debug.ReadBuildInfo()
|
info, ok := debug.ReadBuildInfo()
|
||||||
if !ok {
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ func TestCommandKitBuildIdentityProjectsEffectiveModuleVersion(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "main module tag",
|
name: "main module tag",
|
||||||
info: &debug.BuildInfo{Main: debug.Module{Path: commandKitModulePath, Version: "v1.2.3"}},
|
info: &debug.BuildInfo{Main: debug.Module{Path: "go.maximumdirect.net/commandkit", Version: "v1.2.3"}},
|
||||||
want: "v1.2.3",
|
want: "v1.2.3",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
199
command.go
199
command.go
@@ -6,15 +6,66 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command is an opaque, reusable declaration captured by NewCommand, Group,
|
// Command is an opaque, reusable declaration of canonical commands and groups.
|
||||||
// RootGroup, or an integration. Capture copies declaration slices but retains
|
// NewCommand, Group, RootGroup, and integration command factories return
|
||||||
// callback and caller-owned object references. Installation validates the
|
// Command values. Capture copies declaration slices but retains callback and
|
||||||
// declaration before changing an App registry.
|
// caller-owned object references. Installation validates the declaration
|
||||||
|
// before changing an App registry.
|
||||||
type Command struct{ record *commandRecord }
|
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 {
|
type commandRecord struct {
|
||||||
name string
|
name string
|
||||||
summary string
|
summary string
|
||||||
@@ -23,6 +74,7 @@ type commandRecord struct {
|
|||||||
payload commandPayload
|
payload commandPayload
|
||||||
issues []error
|
issues []error
|
||||||
rootOnly bool
|
rootOnly bool
|
||||||
|
completion bool
|
||||||
absent bool
|
absent bool
|
||||||
invalid bool
|
invalid bool
|
||||||
}
|
}
|
||||||
@@ -47,6 +99,8 @@ type FlagMeta struct {
|
|||||||
Usage string
|
Usage string
|
||||||
// Required reports whether execution requires the flag to be supplied.
|
// Required reports whether execution requires the flag to be supplied.
|
||||||
Required bool
|
Required bool
|
||||||
|
// Completion describes optional value suggestions.
|
||||||
|
Completion CompletionHint
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArgumentMeta describes a positional command argument. Its name and usage are
|
// ArgumentMeta describes a positional command argument. Its name and usage are
|
||||||
@@ -56,6 +110,8 @@ type ArgumentMeta struct {
|
|||||||
Name string
|
Name string
|
||||||
// Usage briefly describes the argument.
|
// Usage briefly describes the argument.
|
||||||
Usage string
|
Usage string
|
||||||
|
// Completion describes optional value suggestions.
|
||||||
|
Completion CompletionHint
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flag represents a typed binding for a command flag. Copies retain binding
|
// Flag represents a typed binding for a command flag. Copies retain binding
|
||||||
@@ -130,7 +186,7 @@ type commandPayload interface {
|
|||||||
hasRun() bool
|
hasRun() bool
|
||||||
bridgeFlags() []commandmeta.Flag
|
bridgeFlags() []commandmeta.Flag
|
||||||
bridgeArguments() []commandmeta.Argument
|
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 {
|
type typedCommand[I any] struct {
|
||||||
@@ -158,6 +214,7 @@ func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag {
|
|||||||
Boolean: binding.boolean,
|
Boolean: binding.boolean,
|
||||||
Repeated: binding.repeated,
|
Repeated: binding.repeated,
|
||||||
Required: binding.meta.Required,
|
Required: binding.meta.Required,
|
||||||
|
Completion: bridgeCompletion(binding.meta.Completion),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return flags
|
return flags
|
||||||
@@ -172,13 +229,14 @@ func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument {
|
|||||||
Usage: binding.meta.Usage,
|
Usage: binding.meta.Usage,
|
||||||
Min: binding.min,
|
Min: binding.min,
|
||||||
Max: binding.max,
|
Max: binding.max,
|
||||||
|
Completion: bridgeCompletion(binding.meta.Completion),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return arguments
|
return arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
|
func (c typedCommand[I]) prepare(flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
|
||||||
prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired)
|
prepared, err := prepareInputWithSpellings(c.spec, flagValues, flagSpellings, operands, enforceRequired)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -192,13 +250,15 @@ func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enfor
|
|||||||
if c.privateRun != nil {
|
if c.privateRun != nil {
|
||||||
return c.privateRun(current.commandContext(), input)
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCommand captures a typed command declaration without constructing its
|
// NewCommand captures a typed canonical command declaration without
|
||||||
// input or running callbacks. Construction issues are retained and reported
|
// constructing its input or running callbacks. It copies the spec's flags,
|
||||||
// when the declaration is installed. The returned declaration can be reused.
|
// 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 {
|
func NewCommand[I any](spec CommandSpec[I]) Command {
|
||||||
return captureCommand(spec, nil)
|
return captureCommand(spec, nil)
|
||||||
}
|
}
|
||||||
@@ -220,25 +280,29 @@ func captureCommand[I any](spec CommandSpec[I], privateRun func(commandExecution
|
|||||||
return Command{record: record}
|
return Command{record: record}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group captures a non-runnable command group and a copy of its relative child
|
// Group captures a non-runnable canonical command group and a copy of its
|
||||||
// slice. Child declarations remain reusable and are not recursively copied.
|
// 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 {
|
func Group(name, summary string, children ...Command) Command {
|
||||||
record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)}
|
record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)}
|
||||||
captureComposition(record)
|
captureComposition(record)
|
||||||
return Command{record: record}
|
return Command{record: record}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RootGroup captures a group that may only be installed at the application root.
|
// RootGroup captures a group that may only be installed at the application
|
||||||
// Installation rejects it below any other command, including through wrappers.
|
// 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 {
|
func RootGroup(name, summary string, children ...Command) Command {
|
||||||
command := Group(name, summary, children...)
|
command := Group(name, summary, children...)
|
||||||
command.record.rootOnly = true
|
command.record.rootOnly = true
|
||||||
return command
|
return command
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithError returns a new declaration that also retains a non-nil err for
|
// WithError returns a new Command declaration that also retains a non-nil err
|
||||||
// installation. Earlier declaration and child errors remain available, and
|
// for installation. Earlier declaration and child errors remain available,
|
||||||
// wrapped error identity is preserved. A nil err returns command unchanged.
|
// and wrapped error identity is preserved. A nil err returns command unchanged.
|
||||||
func WithError(command Command, err error) Command {
|
func WithError(command Command, err error) Command {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return command
|
return command
|
||||||
@@ -254,11 +318,91 @@ func WithError(command Command, err error) Command {
|
|||||||
return Command{record: &record}
|
return Command{record: &record}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoCommand explicitly represents an absent optional declaration. Installation
|
// NoCommand explicitly represents an absent optional command declaration.
|
||||||
// skips it, but reports errors attached to it; a zero Command is invalid and is
|
// Installation skips it, but reports errors attached to it; a zero Command is
|
||||||
// not an absence marker.
|
// invalid and is not an absence marker. Routing rules have no absence marker.
|
||||||
func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
|
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) {
|
func captureComposition(record *commandRecord) {
|
||||||
if err := validateCommandName(record.name); err != nil {
|
if err := validateCommandName(record.name); err != nil {
|
||||||
record.issues = append(record.issues, err)
|
record.issues = append(record.issues, err)
|
||||||
@@ -297,7 +441,7 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
|
|||||||
}
|
}
|
||||||
record := command.record
|
record := command.record
|
||||||
path := append(append([]string(nil), parents...), record.name)
|
path := append(append([]string(nil), parents...), record.name)
|
||||||
label := strings.Join(path, " ")
|
label := commandpath.FromTokens(path).String()
|
||||||
for _, issue := range record.issues {
|
for _, issue := range record.issues {
|
||||||
if label != "" {
|
if label != "" {
|
||||||
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
|
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
|
||||||
@@ -320,11 +464,7 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateCommandName(name string) error {
|
func validateCommandName(name string) error {
|
||||||
fields := strings.Fields(name)
|
return commandpath.ValidateComponent(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
||||||
@@ -338,6 +478,9 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
|||||||
if binding.meta.Name == "help" {
|
if binding.meta.Name == "help" {
|
||||||
return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name)
|
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 {
|
if _, exists := flagNames[binding.meta.Name]; exists {
|
||||||
return fmt.Errorf("duplicate flag name %q", binding.meta.Name)
|
return fmt.Errorf("duplicate flag name %q", binding.meta.Name)
|
||||||
}
|
}
|
||||||
|
|||||||
119
completion.go
Normal file
119
completion.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Completion declares opt-in shell script generation for the executable
|
||||||
|
// basename used to invoke the application. Install it at the application root.
|
||||||
|
// Generated shell identifiers support letters, digits, underscores, dots,
|
||||||
|
// hyphens, and colons; a leading hyphen is not supported.
|
||||||
|
func Completion(executable string) Command {
|
||||||
|
children := make([]Command, 0, 3)
|
||||||
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
|
shell := shell
|
||||||
|
children = append(children, NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: shell, Summary: "Generate " + shell + " completion script.",
|
||||||
|
Run: func(invocation Invocation, _ struct{}) Outcome {
|
||||||
|
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||||
|
return internalcobra.CompletionScript(writer, executable, shell)
|
||||||
|
})
|
||||||
|
return Success()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
command := RootGroup("completion", "Generate shell completion scripts.", children...)
|
||||||
|
command.record.completion = true
|
||||||
|
if !validCompletionExecutable(executable) {
|
||||||
|
return WithError(command, fmt.Errorf("unsupported completion executable basename %q", executable))
|
||||||
|
}
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cobra emits the basename into shell identifiers and unquoted command words.
|
||||||
|
func validCompletionExecutable(name string) bool {
|
||||||
|
if name == "" || name == "." || name == ".." || strings.HasPrefix(name, "-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, ch := range name {
|
||||||
|
if !(unicode.IsLetter(ch) || unicode.IsDigit(ch) || ch == '_' || ch == '-' || ch == ':' || ch == '.') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompletionChoice is a literal suggested value and its optional description.
|
||||||
|
type CompletionChoice struct {
|
||||||
|
// Value is inserted as data; it is not interpreted as shell source.
|
||||||
|
Value string
|
||||||
|
// Description is optional context displayed by shells that support it.
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompletionHint selects suggestions for a flag or positional value. Its zero
|
||||||
|
// value requests no value suggestions; hints do not constrain accepted input.
|
||||||
|
type CompletionHint struct {
|
||||||
|
kind completionHintKind
|
||||||
|
choices *completionChoicesData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the public metadata structs comparable without exposing mutable choices.
|
||||||
|
type completionChoicesData struct {
|
||||||
|
values []CompletionChoice
|
||||||
|
}
|
||||||
|
|
||||||
|
type completionHintKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
completionNone completionHintKind = iota
|
||||||
|
completionChoices
|
||||||
|
completionFiles
|
||||||
|
completionDirectories
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompleteChoices suggests literal values matching the current value prefix.
|
||||||
|
// The supplied slice is copied. An empty list produces no suggestions. Empty
|
||||||
|
// values and values containing a newline, tab, or NUL cannot be presented as
|
||||||
|
// suggestions, but remain valid input if the command's converter accepts them.
|
||||||
|
func CompleteChoices(choices ...CompletionChoice) CompletionHint {
|
||||||
|
hint := CompletionHint{kind: completionChoices}
|
||||||
|
if len(choices) != 0 {
|
||||||
|
hint.choices = &completionChoicesData{values: append([]CompletionChoice(nil), choices...)}
|
||||||
|
}
|
||||||
|
return hint
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteFiles requests shell filename completion.
|
||||||
|
func CompleteFiles() CompletionHint { return CompletionHint{kind: completionFiles} }
|
||||||
|
|
||||||
|
// CompleteDirectories requests shell directory completion.
|
||||||
|
func CompleteDirectories() CompletionHint { return CompletionHint{kind: completionDirectories} }
|
||||||
|
|
||||||
|
// NoCompletion requests no value suggestions.
|
||||||
|
func NoCompletion() CompletionHint { return CompletionHint{} }
|
||||||
|
|
||||||
|
func bridgeCompletion(hint CompletionHint) commandmeta.CompletionHint {
|
||||||
|
result := commandmeta.CompletionHint{}
|
||||||
|
switch hint.kind {
|
||||||
|
case completionChoices:
|
||||||
|
result.Kind = commandmeta.CompletionChoices
|
||||||
|
if hint.choices == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, choice := range hint.choices.values {
|
||||||
|
result.Choices = append(result.Choices, commandmeta.CompletionChoice{Value: choice.Value, Description: choice.Description})
|
||||||
|
}
|
||||||
|
case completionFiles:
|
||||||
|
result.Kind = commandmeta.CompletionFiles
|
||||||
|
case completionDirectories:
|
||||||
|
result.Kind = commandmeta.CompletionDirectories
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
78
completion_context.go
Normal file
78
completion_context.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type completionContext struct {
|
||||||
|
route routeResult
|
||||||
|
command *commandmeta.Command
|
||||||
|
aliases []commandmeta.FlagAlias
|
||||||
|
completed []string
|
||||||
|
tail []string
|
||||||
|
current string
|
||||||
|
commandPosition bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCompletionContext(snapshot *routingSnapshot, completed []string, current string) completionContext {
|
||||||
|
context := completionContext{completed: append([]string(nil), completed...), current: current}
|
||||||
|
if snapshot == nil {
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
context.route = resolveEnteredRoute(completed, snapshot)
|
||||||
|
switch context.route.kind {
|
||||||
|
case routeCanonical:
|
||||||
|
context.command = completionCommand(snapshot, context.route.canonicalPath)
|
||||||
|
if context.command == nil || len(context.route.args) < len(context.route.canonicalPath) {
|
||||||
|
context.command = nil
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
context.tail = append([]string(nil), context.route.args[len(context.route.canonicalPath):]...)
|
||||||
|
context.aliases = snapshot.flagAliases[context.route.canonicalPath.Key()].lookupRecords()
|
||||||
|
context.commandPosition = len(context.tail) == 0
|
||||||
|
case routeDiscovery:
|
||||||
|
context.command = &commandmeta.Command{CoreFlags: commandmeta.ReportingFlags()}
|
||||||
|
context.tail = append([]string(nil), context.route.args...)
|
||||||
|
context.aliases = snapshot.discoveryFlags[context.route.discoveryPath.Key()].lookupRecords()
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionCommand(snapshot *routingSnapshot, path commandpath.Path) *commandmeta.Command {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return snapshot.metadata
|
||||||
|
}
|
||||||
|
children := snapshot.canonical
|
||||||
|
var node *canonicalNode
|
||||||
|
for _, name := range path {
|
||||||
|
node = children[name]
|
||||||
|
if node == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
children = node.children
|
||||||
|
}
|
||||||
|
return node.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionChildren(snapshot *routingSnapshot, context completionContext) []commandmeta.CompletionChoice {
|
||||||
|
if snapshot == nil || context.command == nil || !context.commandPosition {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var choices []commandmeta.CompletionChoice
|
||||||
|
for _, child := range context.command.Children {
|
||||||
|
if !strings.HasPrefix(child.Name, context.current) || (len(context.route.canonicalPath) == 0 && child.Name == internalcobra.CompletionQueryToken) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entered := append(append([]string(nil), context.completed...), child.Name)
|
||||||
|
route := resolveEnteredRoute(entered, snapshot)
|
||||||
|
intended := append(context.route.canonicalPath.Clone(), child.Name)
|
||||||
|
if route.kind == routeCanonical && route.canonicalPath.Equal(intended) {
|
||||||
|
choices = append(choices, commandmeta.CompletionChoice{Value: child.Name, Description: child.Summary})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return choices
|
||||||
|
}
|
||||||
89
completion_context_test.go
Normal file
89
completion_context_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionCommandContext(t *testing.T) {
|
||||||
|
parse := func(value string) (string, error) { return value, nil }
|
||||||
|
mixed := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "mixed", Children: []Command{simpleRunnable("child")},
|
||||||
|
Arguments: []Argument[struct{}]{ValueArgument(ArgumentMeta{Name: "item"}, false, parse, func(*struct{}, Supplied[string]) {})},
|
||||||
|
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "example"},
|
||||||
|
Group("workspace", "", Group("list", "", simpleRunnable("shadow"), simpleRunnable("show"))),
|
||||||
|
simpleRunnable("other"), mixed, Completion("example"),
|
||||||
|
Alias("ws", "workspace"), Alias("w", "ws"), Alias("ws list shadow", "other"),
|
||||||
|
Alias("legacy inspect", "workspace list"), FlagAlias("--silent", "--quiet"),
|
||||||
|
FlagAliasFor("workspace list", "--detail", "--verbose"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot := app.routeSnapshot()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
completed []string
|
||||||
|
current string
|
||||||
|
path string
|
||||||
|
position bool
|
||||||
|
choices []string
|
||||||
|
kind routeKind
|
||||||
|
}{
|
||||||
|
{"root", nil, "work", "", true, []string{"workspace"}, routeCanonical},
|
||||||
|
{"group", []string{"workspace"}, "li", "workspace", true, []string{"list"}, routeCanonical},
|
||||||
|
{"entered alias", []string{"ws", "list"}, "sh", "workspace list", true, []string{"show"}, routeCanonical},
|
||||||
|
{"alias chain", []string{"w", "list"}, "sh", "workspace list", true, []string{"show"}, routeCanonical},
|
||||||
|
{"discovery", []string{"legacy"}, "in", "", false, nil, routeDiscovery},
|
||||||
|
{"complete alias", []string{"legacy", "inspect"}, "sh", "workspace list", true, []string{"shadow", "show"}, routeCanonical},
|
||||||
|
{"runnable child", []string{"mixed"}, "ch", "mixed", true, []string{"child"}, routeCanonical},
|
||||||
|
{"unmatched child", []string{"mixed"}, "other", "mixed", true, nil, routeCanonical},
|
||||||
|
{"runnable operand", []string{"mixed", "value"}, "ch", "mixed", false, nil, routeCanonical},
|
||||||
|
{"flag ends path", []string{"mixed", "--quiet"}, "ch", "mixed", false, nil, routeCanonical},
|
||||||
|
{"flag before child", []string{"workspace", "--quiet"}, "li", "workspace", false, nil, routeCanonical},
|
||||||
|
{"unknown", []string{"missing"}, "x", "", false, nil, routeUnknown},
|
||||||
|
{"hidden endpoint", nil, "__comp", "", true, nil, routeCanonical},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
original := append([]string(nil), tc.completed...)
|
||||||
|
context := resolveCompletionContext(snapshot, tc.completed, tc.current)
|
||||||
|
if context.route.kind != tc.kind || context.route.canonicalPath.String() != tc.path || context.commandPosition != tc.position || !reflect.DeepEqual(tc.completed, original) {
|
||||||
|
t.Fatalf("context=%#v completed=%q", context, tc.completed)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, choice := range completionChildren(snapshot, context) {
|
||||||
|
names = append(names, choice.Value)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(names, tc.choices) {
|
||||||
|
t.Fatalf("children=%q, want %q", names, tc.choices)
|
||||||
|
}
|
||||||
|
if tc.kind == routeDiscovery && !reflect.DeepEqual(context.aliases, []commandmeta.FlagAlias{{Source: "--silent", Target: "--quiet"}}) {
|
||||||
|
t.Fatalf("discovery aliases=%#v", context.aliases)
|
||||||
|
}
|
||||||
|
if (tc.name == "runnable child" || tc.name == "unmatched child") && (context.command == nil || len(context.command.Arguments) != 1) {
|
||||||
|
t.Fatalf("runnable operand metadata missing: %#v", context.command)
|
||||||
|
}
|
||||||
|
if tc.name == "entered alias" {
|
||||||
|
actual := slices.Clone(context.aliases)
|
||||||
|
expected := []commandmeta.FlagAlias{{Source: "--detail", Target: "--verbose"}, {Source: "--silent", Target: "--quiet"}}
|
||||||
|
compare := func(a, b commandmeta.FlagAlias) int {
|
||||||
|
if result := cmp.Compare(a.Source, b.Source); result != 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.Target, b.Target)
|
||||||
|
}
|
||||||
|
slices.SortFunc(actual, compare)
|
||||||
|
slices.SortFunc(expected, compare)
|
||||||
|
if !slices.Equal(actual, expected) {
|
||||||
|
t.Fatalf("canonical aliases=%#v, want %#v", actual, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
217
completion_flags.go
Normal file
217
completion_flags.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
type completionTargetKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
completionTargetNone completionTargetKind = iota
|
||||||
|
completionTargetCommands
|
||||||
|
completionTargetFlags
|
||||||
|
completionTargetValue
|
||||||
|
completionTargetOperand
|
||||||
|
)
|
||||||
|
|
||||||
|
type completionTarget struct {
|
||||||
|
kind completionTargetKind
|
||||||
|
choices []commandmeta.CompletionChoice
|
||||||
|
hint commandmeta.CompletionHint
|
||||||
|
prefix string
|
||||||
|
valuePrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type completionFlagState struct {
|
||||||
|
supplied map[string]bool
|
||||||
|
operands []string
|
||||||
|
pending *commandmeta.Flag
|
||||||
|
ended bool
|
||||||
|
invalid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionFlags(context completionContext) []commandmeta.Flag {
|
||||||
|
flags := make([]commandmeta.Flag, 0, len(context.command.Flags)+len(context.command.CoreFlags)+1)
|
||||||
|
flags = append(flags, commandmeta.HelpFlag())
|
||||||
|
flags = append(flags, context.command.CoreFlags...)
|
||||||
|
flags = append(flags, context.command.Flags...)
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkCompletionFlags(tokens []string, lookup commandmeta.FlagLookup) completionFlagState {
|
||||||
|
state := completionFlagState{supplied: make(map[string]bool)}
|
||||||
|
for index := 0; index < len(tokens); index++ {
|
||||||
|
token := tokens[index]
|
||||||
|
if state.ended {
|
||||||
|
state.operands = append(state.operands, token)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch commandmeta.ClassifyFlagToken(token) {
|
||||||
|
case commandmeta.TerminatorToken:
|
||||||
|
state.ended = true
|
||||||
|
case commandmeta.OperandToken:
|
||||||
|
state.operands = append(state.operands, token)
|
||||||
|
case commandmeta.LongFlagToken:
|
||||||
|
spelling, _, attached := commandmeta.SplitLongFlag(token)
|
||||||
|
flag, ok := lookup.Find(spelling)
|
||||||
|
if !ok {
|
||||||
|
state.invalid = true
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
state.supplied[flag.Name] = true
|
||||||
|
if !flag.Boolean && !attached {
|
||||||
|
if index+1 == len(tokens) {
|
||||||
|
state.pending = &flag
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
case commandmeta.ShortFlagToken:
|
||||||
|
for offset := 1; offset < len(token); offset++ {
|
||||||
|
flag, ok := lookup.Find("-" + token[offset:offset+1])
|
||||||
|
if !ok {
|
||||||
|
state.invalid = true
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
state.supplied[flag.Name] = true
|
||||||
|
suffix := token[offset+1:]
|
||||||
|
if flag.Boolean {
|
||||||
|
if strings.HasPrefix(suffix, "=") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if suffix == "" {
|
||||||
|
if index+1 == len(tokens) {
|
||||||
|
state.pending = &flag
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionTargetFor(snapshot *routingSnapshot, context completionContext) completionTarget {
|
||||||
|
if context.command == nil {
|
||||||
|
return completionTarget{}
|
||||||
|
}
|
||||||
|
flags := completionFlags(context)
|
||||||
|
lookup := commandmeta.NewFlagLookup(flags, context.aliases)
|
||||||
|
state := walkCompletionFlags(context.tail, lookup)
|
||||||
|
if state.invalid {
|
||||||
|
return completionTarget{}
|
||||||
|
}
|
||||||
|
if state.pending != nil {
|
||||||
|
return completionTarget{kind: completionTargetValue, hint: state.pending.Completion, valuePrefix: context.current}
|
||||||
|
}
|
||||||
|
if !state.ended && context.commandPosition {
|
||||||
|
if children := completionChildren(snapshot, context); len(children) > 0 {
|
||||||
|
return completionTarget{kind: completionTargetCommands, choices: children}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !state.ended && strings.HasPrefix(context.current, "-") {
|
||||||
|
return completionOptionTarget(context.current, flags, lookup, state.supplied)
|
||||||
|
}
|
||||||
|
if hint, ok := completionOperandHint(context.command.Arguments, len(state.operands)); ok {
|
||||||
|
return completionTarget{kind: completionTargetOperand, hint: hint, valuePrefix: context.current}
|
||||||
|
}
|
||||||
|
if !state.ended && context.current == "" {
|
||||||
|
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, state.supplied, "")}
|
||||||
|
}
|
||||||
|
return completionTarget{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionOptionTarget(current string, flags []commandmeta.Flag, lookup commandmeta.FlagLookup, supplied map[string]bool) completionTarget {
|
||||||
|
if current == "-" {
|
||||||
|
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(current, "--") {
|
||||||
|
spelling, value, attached := commandmeta.SplitLongFlag(current)
|
||||||
|
if attached {
|
||||||
|
if flag, ok := lookup.Find(spelling); ok {
|
||||||
|
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: spelling + "=", valuePrefix: value}
|
||||||
|
}
|
||||||
|
return completionTarget{}
|
||||||
|
}
|
||||||
|
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
|
||||||
|
}
|
||||||
|
used := make(map[string]bool, len(supplied))
|
||||||
|
for name, value := range supplied {
|
||||||
|
used[name] = value
|
||||||
|
}
|
||||||
|
for offset := 1; offset < len(current); offset++ {
|
||||||
|
flag, ok := lookup.Find("-" + current[offset:offset+1])
|
||||||
|
if !ok {
|
||||||
|
return completionTarget{}
|
||||||
|
}
|
||||||
|
used[flag.Name] = true
|
||||||
|
suffix := current[offset+1:]
|
||||||
|
if flag.Boolean {
|
||||||
|
if strings.HasPrefix(suffix, "=") {
|
||||||
|
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: current[:offset+2], valuePrefix: suffix[1:]}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix := current[:offset+1]
|
||||||
|
if strings.HasPrefix(suffix, "=") {
|
||||||
|
prefix += "="
|
||||||
|
suffix = suffix[1:]
|
||||||
|
}
|
||||||
|
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: prefix, valuePrefix: suffix}
|
||||||
|
}
|
||||||
|
var choices []commandmeta.CompletionChoice
|
||||||
|
for _, flag := range flags {
|
||||||
|
if flag.Short != "" && completionFlagEligible(flag, used) {
|
||||||
|
choices = append(choices, commandmeta.CompletionChoice{Value: current + flag.Short, Description: flag.Usage})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completionTarget{kind: completionTargetFlags, choices: choices}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionFlagNames(flags []commandmeta.Flag, supplied map[string]bool, prefix string) []commandmeta.CompletionChoice {
|
||||||
|
var choices []commandmeta.CompletionChoice
|
||||||
|
for _, flag := range flags {
|
||||||
|
if !completionFlagEligible(flag, supplied) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, spelling := range []string{"--" + flag.Name, shortCompletionSpelling(flag.Short)} {
|
||||||
|
if spelling != "" && strings.HasPrefix(spelling, prefix) {
|
||||||
|
choices = append(choices, commandmeta.CompletionChoice{Value: spelling, Description: flag.Usage})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return choices
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionFlagEligible(flag commandmeta.Flag, supplied map[string]bool) bool {
|
||||||
|
return !supplied[flag.Name] || flag.Repeated || commandmeta.IsReportingFlag(flag.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortCompletionSpelling(short string) string {
|
||||||
|
if short == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "-" + short
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionOperandHint(arguments []commandmeta.Argument, completed int) (commandmeta.CompletionHint, bool) {
|
||||||
|
total := completed + 1
|
||||||
|
counts, err := allocateOperandCounts(total, arguments, false)
|
||||||
|
if err != nil {
|
||||||
|
return commandmeta.CompletionHint{}, false
|
||||||
|
}
|
||||||
|
position := 0
|
||||||
|
for index, count := range counts {
|
||||||
|
position += count
|
||||||
|
if position >= total {
|
||||||
|
return arguments[index].Completion, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commandmeta.CompletionHint{}, false
|
||||||
|
}
|
||||||
146
completion_flags_test.go
Normal file
146
completion_flags_test.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionFlagAndValuePositions(t *testing.T) {
|
||||||
|
parse := func(value string) (string, error) { return value, nil }
|
||||||
|
choice := CompleteChoices(CompletionChoice{Value: "blue"}, CompletionChoice{Value: "green"})
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run",
|
||||||
|
Flags: []Flag[struct{}]{
|
||||||
|
ValueFlag(FlagMeta{Name: "color", Short: "c", Completion: choice}, (*string)(nil), parse, func(*struct{}, Supplied[string]) {}),
|
||||||
|
RepeatFlag(FlagMeta{Name: "tag", Short: "t", Completion: choice}, nil, parse, func(*struct{}, Supplied[[]string]) {}),
|
||||||
|
BoolFlag(FlagMeta{Name: "switch", Short: "s"}, false, func(*struct{}, Supplied[bool]) {}),
|
||||||
|
},
|
||||||
|
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "example"}, command,
|
||||||
|
FlagAliasFor("run", "--colour", "--color"), FlagAliasFor("run", "-C", "--color"), FlagAlias("-v", "--verbose"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot := app.routeSnapshot()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, current, prefix, value string
|
||||||
|
completed []string
|
||||||
|
kind completionTargetKind
|
||||||
|
hint commandmeta.CompletionKind
|
||||||
|
}{
|
||||||
|
{"pending takes option", "--quiet", "", "--quiet", []string{"run", "--color"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"terminator consumed as value", "-x", "", "-x", []string{"run", "--color", "--", "--tag"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"repeated singleton value", "--color=gr", "--color=", "gr", []string{"run", "--color=blue"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"long alias value", "--colour=bl", "--colour=", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"long empty value", "--color=", "--color=", "", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"short alias value", "-sCbl", "-sC", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"short equals", "-sC=bl", "-sC=", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"reporting boolean", "--verbose=f", "--verbose=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"short boolean alias", "-v=f", "-v=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
|
||||||
|
{"application boolean", "--switch=f", "--switch=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionNone},
|
||||||
|
{"scoped alias unavailable at root", "--colour=bl", "", "", nil, completionTargetNone, commandmeta.CompletionNone},
|
||||||
|
{"unknown completed", "--c", "", "", []string{"run", "--unknown"}, completionTargetNone, commandmeta.CompletionNone},
|
||||||
|
{"unknown attached", "--unknown=x", "", "", []string{"run"}, completionTargetNone, commandmeta.CompletionNone},
|
||||||
|
{"terminator", "--color", "", "", []string{"run", "--"}, completionTargetNone, commandmeta.CompletionNone},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
target := completionTargetFor(snapshot, resolveCompletionContext(snapshot, tc.completed, tc.current))
|
||||||
|
if target.kind != tc.kind || target.hint.Kind != tc.hint || target.prefix != tc.prefix || target.valuePrefix != tc.value {
|
||||||
|
t.Fatalf("target=%#v", target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
context := resolveCompletionContext(snapshot, []string{"run", "--colour=blue", "--tag=one", "--verbose"}, "--")
|
||||||
|
target := completionTargetFor(snapshot, context)
|
||||||
|
names := completionChoiceNames(target.choices)
|
||||||
|
if containsCompletionName(names, "--color") || !containsCompletionName(names, "--tag") || !containsCompletionName(names, "--verbose") {
|
||||||
|
t.Fatalf("flag suggestions=%q", names)
|
||||||
|
}
|
||||||
|
cluster := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, "-sv"))
|
||||||
|
if cluster.kind != completionTargetFlags || !containsCompletionName(completionChoiceNames(cluster.choices), "-svc") || containsCompletionName(completionChoiceNames(cluster.choices), "-svs") {
|
||||||
|
t.Fatalf("short cluster=%#v", cluster)
|
||||||
|
}
|
||||||
|
dash := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, "-"))
|
||||||
|
if !containsCompletionName(completionChoiceNames(dash.choices), "--color") || !containsCompletionName(completionChoiceNames(dash.choices), "-c") {
|
||||||
|
t.Fatalf("dash suggestions=%#v", dash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionOperandAllocation(t *testing.T) {
|
||||||
|
arguments := []commandmeta.Argument{
|
||||||
|
{Name: "optional", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}},
|
||||||
|
{Name: "repeated", Min: 0, Max: -1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionDirectories}},
|
||||||
|
{Name: "required", Min: 1, Max: 1, Completion: commandmeta.BooleanCompletion()},
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
completed int
|
||||||
|
want commandmeta.CompletionKind
|
||||||
|
}{
|
||||||
|
{0, commandmeta.CompletionChoices},
|
||||||
|
{1, commandmeta.CompletionChoices},
|
||||||
|
{2, commandmeta.CompletionChoices},
|
||||||
|
} {
|
||||||
|
hint, ok := completionOperandHint(arguments, tc.completed)
|
||||||
|
if !ok || hint.Kind != tc.want {
|
||||||
|
t.Fatalf("%d operands: %#v, %v", tc.completed, hint, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bounded := []commandmeta.Argument{{Name: "only", Max: 1, Completion: commandmeta.BooleanCompletion()}}
|
||||||
|
if _, ok := completionOperandHint(bounded, 1); ok {
|
||||||
|
t.Fatal("excess operand received a hint")
|
||||||
|
}
|
||||||
|
trailing := []commandmeta.Argument{{Name: "first", Min: 1, Max: 1, Completion: commandmeta.BooleanCompletion()}, {Name: "rest", Min: 0, Max: -1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionDirectories}}}
|
||||||
|
if hint, ok := completionOperandHint(trailing, 1); !ok || hint.Kind != commandmeta.CompletionDirectories {
|
||||||
|
t.Fatalf("repeated trailing operand: %#v, %v", hint, ok)
|
||||||
|
}
|
||||||
|
if got, want := completionChoiceNames(commandmeta.BooleanCompletion().Choices), []string{"true", "false"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("boolean choices=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionOperandAndFlagContexts(t *testing.T) {
|
||||||
|
command := &commandmeta.Command{
|
||||||
|
Arguments: []commandmeta.Argument{{Name: "item", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}}},
|
||||||
|
Flags: []commandmeta.Flag{{Name: "name", Completion: commandmeta.BooleanCompletion()}},
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, current string
|
||||||
|
tail []string
|
||||||
|
kind completionTargetKind
|
||||||
|
hint commandmeta.CompletionKind
|
||||||
|
}{
|
||||||
|
{"empty chooses operand", "", nil, completionTargetOperand, commandmeta.CompletionFiles},
|
||||||
|
{"dash chooses flag", "--n", nil, completionTargetFlags, commandmeta.CompletionNone},
|
||||||
|
{"after terminator", "--n", []string{"--"}, completionTargetOperand, commandmeta.CompletionFiles},
|
||||||
|
{"interspersed flag value", "value", []string{"--name", "--quiet"}, completionTargetOperand, commandmeta.CompletionFiles},
|
||||||
|
{"excess operand", "value", []string{"already"}, completionTargetNone, commandmeta.CompletionNone},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
target := completionTargetFor(nil, completionContext{command: command, tail: tc.tail, current: tc.current})
|
||||||
|
if target.kind != tc.kind || target.hint.Kind != tc.hint {
|
||||||
|
t.Fatalf("target=%#v", target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionChoiceNames(choices []commandmeta.CompletionChoice) []string {
|
||||||
|
var names []string
|
||||||
|
for _, choice := range choices {
|
||||||
|
names = append(names, choice.Value)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsCompletionName(names []string, want string) bool {
|
||||||
|
for _, name := range names {
|
||||||
|
if name == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
108
completion_install_test.go
Normal file
108
completion_install_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionDeclarationGeneratesScripts(t *testing.T) {
|
||||||
|
for _, name := range []string{"app", "my-app", "tool.exe", "café"} {
|
||||||
|
if !validCompletionExecutable(name) {
|
||||||
|
t.Fatalf("supported executable rejected: %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app, err := New(Identity{Name: "Human Readable Name"}, Completion("my-app"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !app.routeSnapshot().completion {
|
||||||
|
t.Fatal("completion not present in snapshot")
|
||||||
|
}
|
||||||
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"completion", shell}, Streams{Out: &output, Err: &diagnostics})
|
||||||
|
if outcome.Status != 0 || !strings.Contains(output.String(), "my-app") || strings.Contains(output.String(), "Human Readable Name") {
|
||||||
|
t.Fatalf("%s outcome=%#v output=%q diagnostics=%q", shell, outcome, output.String(), diagnostics.String())
|
||||||
|
}
|
||||||
|
output.Reset()
|
||||||
|
outcome = app.Execute(context.Background(), []string{"completion", shell, "--quiet"}, Streams{Out: &output, Err: &diagnostics})
|
||||||
|
if outcome.Status != 0 || output.Len() != 0 {
|
||||||
|
t.Fatalf("quiet %s outcome=%#v output=%q", shell, outcome, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var help bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"--help"}, Streams{Out: &help})
|
||||||
|
if outcome.Status != 0 || !strings.Contains(help.String(), "completion") || strings.Contains(help.String(), "__complete") {
|
||||||
|
t.Fatalf("help outcome=%#v output=%q", outcome, help.String())
|
||||||
|
}
|
||||||
|
writeErr := errors.New("destination failed")
|
||||||
|
outcome = app.Execute(context.Background(), []string{"completion", "bash"}, Streams{Out: completionErrorWriter{writeErr}, Err: io.Discard})
|
||||||
|
if outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
|
||||||
|
t.Fatalf("script delivery failure: %#v", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type completionErrorWriter struct{ err error }
|
||||||
|
|
||||||
|
func (w completionErrorWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||||
|
|
||||||
|
func TestCompletionInstallationCollisionsAndRollback(t *testing.T) {
|
||||||
|
run := NewCommand(CommandSpec[struct{}]{Name: "run", Run: func(Invocation, struct{}) Outcome { return Success() }})
|
||||||
|
query := NewCommand(CommandSpec[struct{}]{Name: "__complete", Run: func(Invocation, struct{}) Outcome { return Success() }})
|
||||||
|
for _, declarations := range [][]Declaration{
|
||||||
|
{Completion("app"), query},
|
||||||
|
{query, Completion("app")},
|
||||||
|
{Completion("app"), run, Alias("__complete nested", "run")},
|
||||||
|
{run, Alias("__complete nested", "run"), Completion("app")},
|
||||||
|
{Completion("app"), Group("completion", "collision")},
|
||||||
|
{Group("outer", "", Completion("app"))},
|
||||||
|
{Completion("dir/app")},
|
||||||
|
{Completion("two words")},
|
||||||
|
} {
|
||||||
|
if _, err := New(Identity{Name: "example"}, declarations...); err == nil {
|
||||||
|
t.Fatalf("accepted conflicting declarations: %#v", declarations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app, err := New(Identity{Name: "example"}, run)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := app.routeSnapshot()
|
||||||
|
if err := app.Register(Completion("app"), query); err == nil || app.routeSnapshot() != before {
|
||||||
|
t.Fatalf("failed batch changed snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if err := app.Register(Completion("app")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if app.routeSnapshot() == before || !app.routeSnapshot().completion {
|
||||||
|
t.Fatal("late completion installation did not refresh snapshot")
|
||||||
|
}
|
||||||
|
if err := app.Register(query); err == nil {
|
||||||
|
t.Fatal("query command accepted after completion installation")
|
||||||
|
}
|
||||||
|
if err := app.Register(Alias("__complete nested", "run")); err == nil {
|
||||||
|
t.Fatal("query alias accepted after completion installation")
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutCompletion, err := New(Identity{Name: "example"}, query)
|
||||||
|
if err != nil || withoutCompletion.routeSnapshot().completion {
|
||||||
|
t.Fatalf("ordinary query-named command: %v", err)
|
||||||
|
}
|
||||||
|
if outcome := withoutCompletion.Execute(context.Background(), []string{"__complete"}, Streams{}); outcome.Status != 0 {
|
||||||
|
t.Fatalf("ordinary command outcome: %#v", outcome)
|
||||||
|
}
|
||||||
|
if err := withoutCompletion.Register(Completion("app")); err == nil || withoutCompletion.routeSnapshot().completion {
|
||||||
|
t.Fatal("completion accepted an existing query command")
|
||||||
|
}
|
||||||
|
aliased, err := New(Identity{Name: "example"}, run, Alias("__complete nested", "run"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := aliased.Register(Completion("app")); err == nil || aliased.routeSnapshot().completion {
|
||||||
|
t.Fatal("completion accepted an existing query alias")
|
||||||
|
}
|
||||||
|
}
|
||||||
53
completion_query.go
Normal file
53
completion_query.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
func completeRequest(snapshot *routingSnapshot, words []string) (commandmeta.CompletionResult, error) {
|
||||||
|
if len(words) == 0 {
|
||||||
|
return commandmeta.CompletionResult{}, errors.New("completion request has no current word")
|
||||||
|
}
|
||||||
|
context := resolveCompletionContext(snapshot, words[:len(words)-1], words[len(words)-1])
|
||||||
|
target := completionTargetFor(snapshot, context)
|
||||||
|
result := commandmeta.CompletionResult{Prefix: target.prefix}
|
||||||
|
switch target.kind {
|
||||||
|
case completionTargetCommands, completionTargetFlags:
|
||||||
|
result.Kind = commandmeta.CompletionChoices
|
||||||
|
result.Choices = target.choices
|
||||||
|
case completionTargetValue, completionTargetOperand:
|
||||||
|
result.Kind = target.hint.Kind
|
||||||
|
if target.hint.Kind == commandmeta.CompletionChoices {
|
||||||
|
for _, choice := range target.hint.Choices {
|
||||||
|
if strings.HasPrefix(choice.Value, target.valuePrefix) {
|
||||||
|
result.Choices = append(result.Choices, choice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (current *execution) complete(words []string) Outcome {
|
||||||
|
result, err := completeRequest(current.snapshot, words)
|
||||||
|
var reply string
|
||||||
|
if err == nil {
|
||||||
|
reply, err = internalcobra.CompletionReply(result)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
reply = internalcobra.CompletionErrorReply()
|
||||||
|
}
|
||||||
|
_ = current.reporter.RenderOutput(func(writer io.Writer) error {
|
||||||
|
_, writeErr := io.WriteString(writer, reply)
|
||||||
|
return writeErr
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return current.reporter.finalize(Failure("Could not complete the shell request.").WithCause(err), nil)
|
||||||
|
}
|
||||||
|
return current.reporter.finalize(Success(), nil)
|
||||||
|
}
|
||||||
121
completion_query_test.go
Normal file
121
completion_query_test.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionQueryDispatch(t *testing.T) {
|
||||||
|
var factories, converters, handlers int
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "run", Summary: "Run a command.",
|
||||||
|
NewInput: func() struct{} { factories++; return struct{}{} },
|
||||||
|
Flags: []Flag[struct{}]{
|
||||||
|
ValueFlag(FlagMeta{Name: "color", Short: "c", Completion: CompleteChoices(CompletionChoice{Value: "blue", Description: "cool\ncolor"}, CompletionChoice{Value: "green"}, CompletionChoice{Value: "foo=bar"})}, (*string)(nil),
|
||||||
|
func(value string) (string, error) { converters++; return value, nil }, func(*struct{}, Supplied[string]) {}),
|
||||||
|
ValueFlag(FlagMeta{Name: "file", Short: "f", Completion: CompleteFiles()}, (*string)(nil),
|
||||||
|
func(value string) (string, error) { converters++; return value, nil }, func(*struct{}, Supplied[string]) {}),
|
||||||
|
},
|
||||||
|
Run: func(Invocation, struct{}) Outcome { handlers++; return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "example"}, command, Completion("example"), Alias("shortcut", "run"), FlagAliasFor("run", "--colour", "--color"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
emptyReply, err := internalcobra.CompletionReply(commandmeta.CompletionResult{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
empty bool
|
||||||
|
}{
|
||||||
|
{"empty word", []string{"__complete", ""}, "run\tRun a command.", false},
|
||||||
|
{"alias value", []string{"__complete", "shortcut", "--colour=bl"}, "blue\tcool color\n", false},
|
||||||
|
{"quiet is data", []string{"__complete", "run", "--quiet", "--color=bl"}, "blue\tcool color\n", false},
|
||||||
|
{"debug is data", []string{"__complete", "run", "--debug", "--color=bl"}, "blue\tcool color\n", false},
|
||||||
|
{"short attached choice", []string{"__complete", "run", "-cfoo=ba"}, "foo=bar\n", false},
|
||||||
|
{"unknown route", []string{"__complete", "missing", ""}, emptyReply, true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var output, diagnostic bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), tc.args, Streams{Out: &output, Err: &diagnostic})
|
||||||
|
if outcome.Status != 0 || diagnostic.Len() != 0 || (tc.empty && output.String() != tc.want) || (!tc.empty && !strings.Contains(output.String(), tc.want)) {
|
||||||
|
t.Fatalf("outcome=%#v output=%q diagnostics=%q", outcome, output.String(), diagnostic.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fileReply, err := internalcobra.CompletionReply(commandmeta.CompletionResult{Kind: commandmeta.CompletionFiles, Prefix: "-f"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var fileOutput bytes.Buffer
|
||||||
|
fileOutcome := app.Execute(context.Background(), []string{"__complete", "run", "-f./part"}, Streams{Out: &fileOutput})
|
||||||
|
if fileOutcome.Status != 0 || fileOutput.String() != fileReply {
|
||||||
|
t.Fatalf("file query outcome=%#v reply=%q", fileOutcome, fileOutput.String())
|
||||||
|
}
|
||||||
|
if factories != 0 || converters != 0 || handlers != 0 {
|
||||||
|
t.Fatalf("query invoked application callbacks: %d %d %d", factories, converters, handlers)
|
||||||
|
}
|
||||||
|
if err := app.Register(simpleRunnable("later")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
if outcome := app.Execute(context.Background(), []string{"__complete", "lat"}, Streams{Out: &output}); outcome.Status != 0 || !strings.Contains(output.String(), "later") {
|
||||||
|
t.Fatalf("later command outcome=%#v reply=%q", outcome, output.String())
|
||||||
|
}
|
||||||
|
output.Reset()
|
||||||
|
if outcome := app.Execute(context.Background(), []string{"__complete", "later", "--qui"}, Streams{Out: &output}); outcome.Status != 0 || !strings.Contains(output.String(), "--quiet") {
|
||||||
|
t.Fatalf("later command flag outcome=%#v reply=%q", outcome, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionQueryErrorsAndDelivery(t *testing.T) {
|
||||||
|
app, err := New(Identity{Name: "example"}, Completion("example"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output, diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"__complete"}, Streams{Out: &output, Err: &diagnostics})
|
||||||
|
if outcome.Status != 1 || outcome.Cause == nil || output.String() != internalcobra.CompletionErrorReply() || diagnostics.Len() == 0 {
|
||||||
|
t.Fatalf("malformed query outcome=%#v reply=%q diagnostics=%q", outcome, output.String(), diagnostics.String())
|
||||||
|
}
|
||||||
|
writeErr := errors.New("reply destination failed")
|
||||||
|
writer := &completionCountingWriter{err: writeErr}
|
||||||
|
outcome = app.Execute(context.Background(), []string{"__complete", ""}, Streams{Out: writer, Err: io.Discard})
|
||||||
|
if outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || writer.calls != 1 {
|
||||||
|
t.Fatalf("reply write failure: %#v", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type completionCountingWriter struct {
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (writer *completionCountingWriter) Write([]byte) (int, error) {
|
||||||
|
writer.calls++
|
||||||
|
return 0, writer.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionQueryDiscovery(t *testing.T) {
|
||||||
|
app, err := New(Identity{Name: "example"}, Completion("example"),
|
||||||
|
Group("workspace", "", simpleRunnable("inspect")), Alias("legacy inspect", "workspace inspect"), FlagAlias("--silent", "--quiet"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"__complete", "legacy", "--"}, Streams{Out: &output})
|
||||||
|
if outcome.Status != 0 || !strings.Contains(output.String(), "--quiet") || strings.Contains(output.String(), "inspect") || strings.Contains(output.String(), "--silent") {
|
||||||
|
t.Fatalf("discovery outcome=%#v reply=%q", outcome, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
93
completion_shell_test.go
Normal file
93
completion_shell_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionConsumerInShells(t *testing.T) {
|
||||||
|
if os.Getenv("COMMANDKIT_COMPLETION_SHELLS") != "1" {
|
||||||
|
t.Skip("set COMMANDKIT_COMPLETION_SHELLS=1 to run shell completion checks")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
app := filepath.Join(dir, "probe")
|
||||||
|
build := exec.Command("go", "build", "-o", app, "./testdata/completion")
|
||||||
|
if output, err := build.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("build completion fixture: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "partial dir"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "partial file"), nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name, line string
|
||||||
|
want []string
|
||||||
|
directory bool
|
||||||
|
}{
|
||||||
|
{"canonical command", "probe ru", []string{"run"}, false},
|
||||||
|
{"entered alias", "probe shortcut --format=ya", []string{"shortcut", "--format=yaml"}, false},
|
||||||
|
{"choice", "probe run --format=te", []string{"run", "--format=text"}, false},
|
||||||
|
{"no file fallback", `probe run --format=./partial\ f`, []string{"run", "--format=./partial f"}, false},
|
||||||
|
{"separated file", `probe run --config ./partial\ f`, []string{"run", "--config", "./partial file"}, false},
|
||||||
|
{"attached file", `probe run --config=./partial\ f`, []string{"run", "--config=./partial file"}, false},
|
||||||
|
{"short attached file", `probe run -c./partial\ f`, []string{"run", "-c./partial file"}, false},
|
||||||
|
{"short cluster equals file", `probe run -sc=./partial\ f`, []string{"run", "-sc=./partial file"}, false},
|
||||||
|
{"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true},
|
||||||
|
{"attached directory", "probe run --directory=./partial", []string{"run", "--directory=./partial dir"}, true},
|
||||||
|
{"empty operand", "probe run ", []string{"run", "alpha"}, false},
|
||||||
|
{"leading dash", "probe run --for", []string{"run", "--format"}, false},
|
||||||
|
}
|
||||||
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
|
generator := exec.Command(app, "completion", shell)
|
||||||
|
script, err := generator.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate %s script: %v\n%s", shell, err, script)
|
||||||
|
}
|
||||||
|
scriptPath := filepath.Join(dir, shell+".completion")
|
||||||
|
if err := os.WriteFile(scriptPath, script, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(shell+"/"+tc.name, func(t *testing.T) {
|
||||||
|
log := filepath.Join(dir, "log")
|
||||||
|
if err := os.WriteFile(log, nil, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, tc.line)
|
||||||
|
terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+log, "PROBE_DIR="+dir)
|
||||||
|
if output, err := terminal.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("shell completion: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
if err := json.Unmarshal(content, &got); err != nil {
|
||||||
|
t.Fatalf("fixture output %q: %v", content, err)
|
||||||
|
}
|
||||||
|
if tc.directory && len(got) > 0 {
|
||||||
|
got[len(got)-1] = strings.TrimSuffix(got[len(got)-1], "/")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Fatalf("edited arguments=%q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run(shell+"/descriptions", func(t *testing.T) {
|
||||||
|
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, "probe run --format=")
|
||||||
|
terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+filepath.Join(dir, "log"), "PROBE_DIR="+dir, "PROBE_LIST=1")
|
||||||
|
output, err := terminal.CombinedOutput()
|
||||||
|
if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") {
|
||||||
|
t.Fatalf("completion descriptions: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
81
completion_test.go
Normal file
81
completion_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Existing consumer code can compare these metadata values or use them as keys.
|
||||||
|
var (
|
||||||
|
_ map[FlagMeta]struct{}
|
||||||
|
_ map[ArgumentMeta]struct{}
|
||||||
|
_ = (FlagMeta{}) == (FlagMeta{})
|
||||||
|
_ = (ArgumentMeta{}) == (ArgumentMeta{})
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionHintsFollowCapturedBindings(t *testing.T) {
|
||||||
|
type inner struct{ values Supplied[[]string] }
|
||||||
|
type outer struct{ flags FlagValues[inner] }
|
||||||
|
choices := []CompletionChoice{{Value: "alpha", Description: "first"}}
|
||||||
|
hint := CompleteChoices(choices...)
|
||||||
|
choices[0] = CompletionChoice{Value: "changed"}
|
||||||
|
parse := func(value string) (string, error) { return value, nil }
|
||||||
|
repeated := RepeatFlag(FlagMeta{Name: "tag", Completion: hint}, nil, parse,
|
||||||
|
func(input *inner, value Supplied[[]string]) { input.values = value })
|
||||||
|
captured := CaptureFlags([]Flag[inner]{repeated}, func(input *outer) *FlagValues[inner] { return &input.flags })
|
||||||
|
command := NewCommand(CommandSpec[outer]{
|
||||||
|
Name: "run",
|
||||||
|
Flags: append(captured,
|
||||||
|
ValueFlag(FlagMeta{Name: "file", Completion: CompleteFiles()}, (*string)(nil), parse, func(*outer, Supplied[string]) {}),
|
||||||
|
BoolFlag(FlagMeta{Name: "enabled"}, false, func(*outer, Supplied[bool]) {}),
|
||||||
|
ValueFlag(FlagMeta{Name: "empty", Completion: CompleteChoices()}, (*string)(nil), parse, func(*outer, Supplied[string]) {})),
|
||||||
|
Arguments: []Argument[outer]{
|
||||||
|
ValueArgument(ArgumentMeta{Name: "directory", Completion: CompleteDirectories()}, false, parse, func(*outer, Supplied[string]) {}),
|
||||||
|
RepeatArgument(ArgumentMeta{Name: "values", Completion: hint}, 0, -1, parse, func(*outer, Supplied[[]string]) {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
flags := command.record.payload.bridgeFlags()
|
||||||
|
arguments := command.record.payload.bridgeArguments()
|
||||||
|
want := commandmeta.CompletionHint{Kind: commandmeta.CompletionChoices, Choices: []commandmeta.CompletionChoice{{Value: "alpha", Description: "first"}}}
|
||||||
|
if !flags[0].Repeated || !reflect.DeepEqual(flags[0].Completion, want) || !reflect.DeepEqual(arguments[1].Completion, want) {
|
||||||
|
t.Fatalf("captured hints: flags=%#v arguments=%#v", flags, arguments)
|
||||||
|
}
|
||||||
|
if flags[1].Completion.Kind != commandmeta.CompletionFiles || flags[2].Completion.Kind != commandmeta.CompletionNone || flags[3].Completion.Kind != commandmeta.CompletionChoices || len(flags[3].Completion.Choices) != 0 || arguments[0].Completion.Kind != commandmeta.CompletionDirectories {
|
||||||
|
t.Fatalf("hint strategies: flags=%#v arguments=%#v", flags, arguments)
|
||||||
|
}
|
||||||
|
flags[0].Completion.Choices[0].Value = "mutated"
|
||||||
|
if got := command.record.payload.bridgeFlags()[0].Completion; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("bridged choices changed captured hint: %#v", got)
|
||||||
|
}
|
||||||
|
if got := bridgeCompletion(NoCompletion()); got.Kind != commandmeta.CompletionNone || !reflect.DeepEqual(got, bridgeCompletion(CompletionHint{})) {
|
||||||
|
t.Fatalf("no completion: %#v", got)
|
||||||
|
}
|
||||||
|
for _, flag := range append(commandmeta.ReportingFlags(), commandmeta.HelpFlag()) {
|
||||||
|
if !reflect.DeepEqual(flag.Completion.Choices, []commandmeta.CompletionChoice{{Value: "true"}, {Value: "false"}}) {
|
||||||
|
t.Fatalf("reporting flag choices: %#v", flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionChoicesDoNotRestrictExecution(t *testing.T) {
|
||||||
|
type input struct{ mode Supplied[string] }
|
||||||
|
var received input
|
||||||
|
command := NewCommand(CommandSpec[input]{
|
||||||
|
Name: "run",
|
||||||
|
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "mode", Completion: CompleteChoices(CompletionChoice{Value: "suggested"})}, (*string)(nil),
|
||||||
|
func(value string) (string, error) { return value, nil },
|
||||||
|
func(i *input, value Supplied[string]) { i.mode = value })},
|
||||||
|
Run: func(_ Invocation, value input) Outcome { received = value; return Success() },
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "example"}, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outcome := app.Execute(context.Background(), []string{"run", "--mode=other"}, Streams{})
|
||||||
|
if outcome.Status != 0 || !received.mode.Set || received.mode.Value != "other" {
|
||||||
|
t.Fatalf("outcome=%#v input=%#v", outcome, received)
|
||||||
|
}
|
||||||
|
}
|
||||||
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 "go.maximumdirect.net/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
|
// Package commandkit provides the core instance-owned CLI dispatcher, typed
|
||||||
// command declarations, input bindings, and invocation outcomes. Optional
|
// command declarations, application-owned command and flag aliases, input
|
||||||
// integration modules provide ConfigKit and PromptKit-specific declarations;
|
// 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.
|
// importing those modules does not install their commands.
|
||||||
package commandkit
|
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.
|
||||||
217
docs/cli.md
217
docs/cli.md
@@ -66,6 +66,130 @@ version: dev
|
|||||||
revision: ""
|
revision: ""
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Shell completion
|
||||||
|
|
||||||
|
Applications may install shell completion explicitly. When installed, generate
|
||||||
|
and load the script for the executable name chosen by the application:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Bash, after initializing bash-completion
|
||||||
|
source <(app completion bash)
|
||||||
|
# Zsh, after initializing its completion system with compinit
|
||||||
|
source <(app completion zsh)
|
||||||
|
# Fish
|
||||||
|
app completion fish | source
|
||||||
|
```
|
||||||
|
|
||||||
|
Bash requires the `bash-completion` package and its helpers in the current
|
||||||
|
shell. If your startup files do not initialize it, source the package's
|
||||||
|
initialization file before loading the generated script. For example, on
|
||||||
|
systems that install it at the following path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
source /usr/share/bash-completion/bash_completion
|
||||||
|
source <(app completion bash)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the path provided by your Bash completion installation.
|
||||||
|
|
||||||
|
The application selects the executable basename at installation; use that same
|
||||||
|
name to invoke the generated script. Completion offers canonical command and
|
||||||
|
flag names, static values, and file or directory paths where declarations
|
||||||
|
provide hints. A fully entered alias is resolved to its canonical command;
|
||||||
|
alias spellings and partial alias paths are not suggested. Completion queries
|
||||||
|
do not invoke input factories, providers, or the selected command; application
|
||||||
|
startup remains application-owned. See the
|
||||||
|
[consumer guide](consumers/guide.md#shell-completion) for installation and
|
||||||
|
hint declarations. Script generation follows the [reporting policy](#reporting),
|
||||||
|
including quiet mode.
|
||||||
|
|
||||||
|
## 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
|
## Registered command syntax
|
||||||
|
|
||||||
Options are parsed after the complete command path and may be interspersed with
|
Options are parsed after the complete command path and may be interspersed with
|
||||||
@@ -83,6 +207,74 @@ Typed inputs keep supplied state separate from defaults. An explicit empty,
|
|||||||
false, or zero value is supplied. Help and version are available without
|
false, or zero value is supplied. Help and version are available without
|
||||||
constructing application command inputs.
|
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
|
## Configuration scopes
|
||||||
|
|
||||||
An application installs the declaration returned by
|
An application installs the declaration returned by
|
||||||
@@ -155,8 +347,8 @@ interrupt the native value. Existing native markers and leading comments are
|
|||||||
retained; documents without a marker receive one. Scope labels containing line
|
retained; documents without a marker receive one. Scope labels containing line
|
||||||
control characters are quoted to keep them on one comment line. A provider
|
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
|
failure leaves the output stream empty. A rendering or output failure may leave
|
||||||
an aggregate prefix, and the outcome reports
|
an aggregate prefix; `Outcome.Presentation.Output` records incomplete delivery
|
||||||
incomplete report delivery.
|
and the destination or rendering error.
|
||||||
|
|
||||||
### List configuration sources
|
### List configuration sources
|
||||||
|
|
||||||
@@ -322,8 +514,9 @@ again.
|
|||||||
The complete report is prepared before writing. Report preparation failures and
|
The complete report is prepared before writing. Report preparation failures and
|
||||||
cancellation observed before report delivery starts leave CommandKit's report
|
cancellation observed before report delivery starts leave CommandKit's report
|
||||||
output empty. Once writing starts, CommandKit does not interrupt the supplied
|
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
|
writer or roll back delivered bytes. A write error or short write is recorded
|
||||||
existing incomplete-report delivery outcome and can leave partial output.
|
as incomplete delivery in `Outcome.Presentation.Output` and can leave partial
|
||||||
|
output.
|
||||||
|
|
||||||
## PromptKit definition inspection
|
## PromptKit definition inspection
|
||||||
|
|
||||||
@@ -539,8 +732,9 @@ execution. See PromptKit's
|
|||||||
Builder, engine-loading, and native validation errors use the operational path
|
Builder, engine-loading, and native validation errors use the operational path
|
||||||
(status `1`) with supported error identity and acquired diagnostics retained.
|
(status `1`) with supported error identity and acquired diagnostics retained.
|
||||||
Malformed syntax, missing operands, and unsupported formats use usage status
|
Malformed syntax, missing operands, and unsupported formats use usage status
|
||||||
`2`. Successful diagnostics and report-delivery failures follow the common
|
`2`. Successful diagnostics use the common reporting policy. Report delivery
|
||||||
delivery contract.
|
failures are recorded in `Outcome.Presentation.Output`, separately from the
|
||||||
|
operation cause.
|
||||||
|
|
||||||
### PromptKit catalogs
|
### PromptKit catalogs
|
||||||
|
|
||||||
@@ -734,8 +928,8 @@ outcome. Operation errors use operational status `1`; aggregate runs continue
|
|||||||
after individual errors, retain their reports and ordered error text, and join
|
after individual errors, retain their reports and ordered error text, and join
|
||||||
causes for supported `errors.Is`/`errors.As` access. Cancellation stops later
|
causes for supported `errors.Is`/`errors.As` access. Cancellation stops later
|
||||||
operations and preserves results already acquired. Report encoding or delivery
|
operations and preserves results already acquired. Report encoding or delivery
|
||||||
failures use the common incomplete-report delivery outcome and retain operation
|
failures are recorded in `Outcome.Presentation.Output`, separately from
|
||||||
causes where applicable. Syntax, operand, unsupported format, and conflicting
|
operation causes. Syntax, operand, unsupported format, and conflicting
|
||||||
namespace selection are usage status `2`.
|
namespace selection are usage status `2`.
|
||||||
|
|
||||||
Native configuration, selection, and diagnostic meanings remain owned by
|
Native configuration, selection, and diagnostic meanings remain owned by
|
||||||
@@ -748,4 +942,9 @@ and [engine API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f
|
|||||||
CommandKit usage failures use status `2`; operational failures use status `1`.
|
CommandKit usage failures use status `2`; operational failures use status `1`.
|
||||||
Successful operations use status `0`. Application handlers may return a domain
|
Successful operations use status `0`. Application handlers may return a domain
|
||||||
classification and status of their choice, which `Execute` preserves. The caller
|
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,18 +1,28 @@
|
|||||||
# Consumer guide
|
# Consumer guide
|
||||||
|
|
||||||
CommandKit is distributed as three Go modules. Start with the core and add an
|
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
|
integration module only when the application uses that library. Core and
|
||||||
returns core `commandkit.Command` declarations, which the application installs
|
compatible integration command factories return canonical `commandkit.Command` values.
|
||||||
through `commandkit.New` or `App.Register`.
|
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
|
## Module selection
|
||||||
|
|
||||||
| Import | Use when |
|
| Import | Use when |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `gitea.maximumdirect.net/eric/CommandKit` | The application needs typed command declarations and common CLI behavior. |
|
| `go.maximumdirect.net/commandkit` | The application needs typed command declarations and common CLI behavior. |
|
||||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
|
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
|
||||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
|
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
|
||||||
|
|
||||||
|
The integration paths in the table are their current source identities; they
|
||||||
|
use the old core path and do not compose with `go.maximumdirect.net/commandkit`.
|
||||||
|
For the new core v0.3.0 identity, the planned matched releases are
|
||||||
|
`go.maximumdirect.net/ckconfig v0.2.0` and `go.maximumdirect.net/ckprompt v0.2.0`.
|
||||||
|
Update application imports and dependency versions together when those releases
|
||||||
|
are available. Old and new core paths have distinct Go type identities.
|
||||||
|
Install the new core with `go get go.maximumdirect.net/commandkit@v0.3.0`.
|
||||||
|
|
||||||
The two integrations are independent optional imports. `ckprompt` uses the
|
The two integrations are independent optional imports. `ckprompt` uses the
|
||||||
public scope and error contracts from `ckconfig`; importing it does not add the
|
public scope and error contracts from `ckconfig`; importing it does not add the
|
||||||
`config` family. An application that wants both families passes both returned
|
`config` family. An application that wants both families passes both returned
|
||||||
@@ -22,9 +32,10 @@ core-only, ConfigKit-only, and composed applications.
|
|||||||
## Go compatibility
|
## Go compatibility
|
||||||
|
|
||||||
All three modules declare a consumer minimum of Go 1.26.0. The root module also
|
All three modules declare a consumer minimum of Go 1.26.0. The root module also
|
||||||
selects ReleaseKit v0.4.2 as a Go tool, which makes that tool's module graph part
|
selects ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) as a Go tool, which
|
||||||
of the root consumer module requirements even though production packages do not
|
makes that tool's module graph part of the root consumer module requirements
|
||||||
import ReleaseKit. The [release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
|
even though production packages do not import ReleaseKit. The
|
||||||
|
[release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
|
||||||
defines the support assumption. Maintainers use Go 1.26.8 for validation and
|
defines the support assumption. Maintainers use Go 1.26.8 for validation and
|
||||||
publication; see the [release procedure](../release.md#release-toolchain).
|
publication; see the [release procedure](../release.md#release-toolchain).
|
||||||
|
|
||||||
@@ -41,7 +52,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GreetInput struct {
|
type GreetInput struct {
|
||||||
@@ -62,7 +73,9 @@ func Greeting() commandkit.Command {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
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()
|
return commandkit.Success()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -83,6 +96,116 @@ values convert before input construction. A command's `NewInput` creates input
|
|||||||
only when execution reaches it; help does not construct caller input or run
|
only when execution reaches it; help does not construct caller input or run
|
||||||
provider callbacks.
|
provider callbacks.
|
||||||
|
|
||||||
|
## Shell completion
|
||||||
|
|
||||||
|
Install `commandkit.Completion("app")` alongside the application's other
|
||||||
|
declarations, using the basename operators actually type. The basename accepts
|
||||||
|
letters, digits, underscores, dots, hyphens, and colons, except for a leading
|
||||||
|
hyphen. Add a hint to a flag or operand's metadata when a value has useful
|
||||||
|
static choices or is a filesystem path:
|
||||||
|
|
||||||
|
```go
|
||||||
|
commandkit.FlagMeta{
|
||||||
|
Name: "format",
|
||||||
|
Completion: commandkit.CompleteChoices(
|
||||||
|
commandkit.CompletionChoice{Value: "text", Description: "Readable text"},
|
||||||
|
commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
commandkit.FlagMeta{Name: "config", Completion: commandkit.CompleteFiles()}
|
||||||
|
commandkit.ArgumentMeta{Name: "directory", Completion: commandkit.CompleteDirectories()}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hints are suggestions: converters and input validation still decide which
|
||||||
|
values are accepted. An omitted hint or `NoCompletion()` gives no value
|
||||||
|
suggestions. `CompleteChoices()` with no choices also gives no suggestions.
|
||||||
|
Declarations retain copies of supplied choices. The [CLI reference](../cli.md#shell-completion)
|
||||||
|
describes supported shells and loading scripts; the [core example](../../examples/core)
|
||||||
|
and [composed example](../../integrations/ckprompt/examples/composed) show
|
||||||
|
installation alongside aliases and integration commands.
|
||||||
|
|
||||||
|
## 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
|
## ConfigKit scopes
|
||||||
|
|
||||||
For an application with one ordinary app configuration loader,
|
For an application with one ordinary app configuration loader,
|
||||||
@@ -216,10 +339,11 @@ application-owned `session restore` command.
|
|||||||
## Outcomes and ownership
|
## Outcomes and ownership
|
||||||
|
|
||||||
Core classifies usage and operational failures while preserving application
|
Core classifies usage and operational failures while preserving application
|
||||||
domain outcomes and supported error identity. Report and diagnostic delivery
|
domain outcomes and supported error identity. Applications choose domain
|
||||||
errors remain inspectable separately from the operation cause. The caller owns
|
classification and status. A terminal explanation is presented separately from
|
||||||
process startup, signal handling, and exit mapping; `Execute` never exits the
|
the optional operation cause, while report and diagnostic delivery observations
|
||||||
process.
|
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
|
Go declarations and GoDoc own exact exported contracts. ConfigKit owns
|
||||||
configuration decoding, validation, and provenance. PromptKit owns prompt
|
configuration decoding, validation, and provenance. PromptKit owns prompt
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
CommandKit is distributed as a core Go module and two optional integration
|
CommandKit is distributed as a core Go module and two optional integration
|
||||||
modules. Core owns captured declarations, typed input preparation, installation,
|
modules. Core owns captured declarations, typed input preparation, installation,
|
||||||
dispatch, outcomes, help, and version. `integrations/ckconfig` owns ConfigKit
|
dispatch, invocation reporting and outcomes, help, and version.
|
||||||
scope adaptation and reports; `integrations/ckprompt` owns PromptKit engine,
|
`integrations/ckconfig` owns ConfigKit scope adaptation and reports;
|
||||||
|
`integrations/ckprompt` owns PromptKit engine,
|
||||||
definition inspection, requirements discovery, and concrete request validation.
|
definition inspection, requirements discovery, and concrete request validation.
|
||||||
The maintained [consumer guide](consumers/guide.md) links to the three maintained
|
The maintained [consumer guide](consumers/guide.md) links to the three maintained
|
||||||
consumers.
|
consumers.
|
||||||
@@ -11,6 +12,12 @@ The [README](../README.md) provides project orientation; the
|
|||||||
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
||||||
canonical documentation owners.
|
canonical documentation owners.
|
||||||
|
|
||||||
|
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
|
## Initial orientation
|
||||||
|
|
||||||
1. Inspect the worktree and preserve unrelated staged and unstaged changes.
|
1. Inspect the worktree and preserve unrelated staged and unstaged changes.
|
||||||
@@ -47,6 +54,8 @@ canonical documentation owners.
|
|||||||
| Task | Read |
|
| Task | Read |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Implemented CLI behavior | [CLI reference](cli.md) |
|
| 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 |
|
| 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) |
|
| 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) |
|
| 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
|
current exported contracts; the CLI reference owns operator behavior. The
|
||||||
[maintainer release procedure](release.md) owns current release operations.
|
[maintainer release procedure](release.md) owns current release operations.
|
||||||
Completed roadmaps and implementation evidence remain available in Git history,
|
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
|
These records are not assignments to restart completed work. The
|
||||||
[deferred-work record](roadmap/deferred.md) preserves future capability targets
|
[deferred-work record](roadmap/deferred.md) preserves future capability targets
|
||||||
and unselected proposals separately from current contracts.
|
and unselected proposals separately from current contracts.
|
||||||
@@ -89,7 +99,10 @@ Sibling-checkout references in retired planning records are historical review
|
|||||||
evidence, not build dependencies. Implemented contracts belong in CommandKit
|
evidence, not build dependencies. Implemented contracts belong in CommandKit
|
||||||
under their canonical owners.
|
under their canonical owners.
|
||||||
|
|
||||||
The root module does not traverse nested modules. During development, use a
|
The root module does not traverse nested modules. During the vanity-path
|
||||||
|
migration, integrations still import the old core identity and cannot compose
|
||||||
|
with the migrated root until their own module migrations are complete. After
|
||||||
|
the matched migrations, during development use a
|
||||||
local workspace for all three modules; it is ignored and must not be committed.
|
local workspace for all three modules; it is ignored and must not be committed.
|
||||||
If a local `go.work` already exists, preserve it and add these module directories:
|
If a local `go.work` already exists, preserve it and add these module directories:
|
||||||
|
|
||||||
@@ -114,14 +127,10 @@ publication commands.
|
|||||||
|
|
||||||
## Release tooling development
|
## Release tooling development
|
||||||
|
|
||||||
The local publication exercise is opt-in and uses disposable Git repositories.
|
Run the focused launcher wiring tests with `go test ./scripts`. The release
|
||||||
Prepare the module cache before running it; ordinary tests do not bootstrap or
|
profiles select upstream checks of committed inputs independently for each
|
||||||
publish a release:
|
module; these checks do not replace domain review. See the
|
||||||
|
[release procedure](release.md) for publication, delivery, and recovery.
|
||||||
```sh
|
|
||||||
GOWORK=off GOTOOLCHAIN=local go mod download
|
|
||||||
COMMANDKIT_RELEASE_INTEGRATION=1 GOWORK=off GOTOOLCHAIN=local GOPROXY=off GONOPROXY=none GOSUMDB=off go test -mod=readonly -count=1 ./scripts -run '^TestReleaseIntegration$'
|
|
||||||
```
|
|
||||||
|
|
||||||
Follow the [local commit policy](policy/agents.md#git-commit-policy). Completed
|
Follow the [local commit policy](policy/agents.md#git-commit-policy). Completed
|
||||||
units receive local commits by default, subject to explicit user instructions.
|
units receive local commits by default, subject to explicit user instructions.
|
||||||
@@ -158,3 +167,17 @@ go run ./examples/core workspace greet --count=0 --tag=first --tag second
|
|||||||
|
|
||||||
The [consumer guide](consumers/guide.md) explains how their public API usage
|
The [consumer guide](consumers/guide.md) explains how their public API usage
|
||||||
fits together.
|
fits together.
|
||||||
|
|
||||||
|
|
||||||
|
## Shell completion validation
|
||||||
|
|
||||||
|
The opt-in interactive completion checks require Bash, Zsh, Fish, Python with
|
||||||
|
`pexpect`, and the Bash completion package. The harness starts a clean Bash
|
||||||
|
and sources `/usr/share/bash-completion/bash_completion` by default. Set
|
||||||
|
`COMPLETION_BASH_INIT` to your installation's initialization file if it is
|
||||||
|
elsewhere. See the [operator setup](cli.md#shell-completion) for ordinary use.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
COMMANDKIT_COMPLETION_SHELLS=1 go test -count=1 -run '^TestCompletionShells$' ./internal/cobra
|
||||||
|
COMMANDKIT_COMPLETION_SHELLS=1 go test -count=1 -run '^TestCompletionConsumerInShells$' .
|
||||||
|
```
|
||||||
|
|||||||
@@ -6,20 +6,61 @@ own the optional ConfigKit and PromptKit integrations. The
|
|||||||
[architecture policy](../policy/architecture.md) owns normative boundaries;
|
[architecture policy](../policy/architecture.md) owns normative boundaries;
|
||||||
this page inventories the implemented owners.
|
this page inventories the implemented owners.
|
||||||
|
|
||||||
|
The core module now declares `go.maximumdirect.net/commandkit`. The integration
|
||||||
|
modules retain their old module and core import identities until their planned
|
||||||
|
matched releases; Go treats those identities as distinct.
|
||||||
|
|
||||||
## Root `commandkit`
|
## Root `commandkit`
|
||||||
|
|
||||||
The root facade owns captured command declarations, binding conversion,
|
The root facade owns captured canonical command declarations, application-owned
|
||||||
invocation coordination, the per-App registry, standard help/version commands,
|
routing rules, binding conversion, invocation coordination, the per-App
|
||||||
and outcome/diagnostic delivery. Its dependency graph has no ConfigKit or
|
registries, standard help/version commands, invocation reporting, and outcome
|
||||||
PromptKit integration dependency. Applications provide typed callbacks and
|
finalization.
|
||||||
choose when to install declarations.
|
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.
|
Canonical commands own handlers and parsing/help metadata; routing rules resolve
|
||||||
The batch installer validates retained issues, placement, and collisions before
|
entered command paths and flag spellings to those canonical records without
|
||||||
mutating the registry. Each invocation uses one routing snapshot and backend
|
adding command nodes or handlers. The shared command compiler prepares supplied
|
||||||
session; a successful registration invalidates the snapshot for a later
|
values before constructing input. The batch installer validates retained
|
||||||
invocation. The root package owns app input and callback execution, not provider
|
issues, placement, and collisions across both registries before publishing
|
||||||
configuration semantics.
|
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`
|
## `internal/commandmeta`
|
||||||
|
|
||||||
|
|||||||
@@ -139,16 +139,16 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
|||||||
|
|
||||||
## ReleaseKit
|
## ReleaseKit
|
||||||
|
|
||||||
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.2` (selected Go tool; not a runtime import).
|
Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (selected Go tool; not a runtime import).
|
||||||
|
|
||||||
Copyright (C) 2026 Eric Rakestraw
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/LICENSE).
|
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8).
|
[Source](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4).
|
||||||
|
|
||||||
## Goldmark
|
## Goldmark
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.3
|
**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)
|
**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
|
commands, including those outside its dispatcher, under
|
||||||
[ADR 0006](../adr/0006-command-grammar.md).
|
[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
|
Use explicit instance construction and composable command registration, not
|
||||||
package-global or import-time registries. Applications and integrations supply
|
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;
|
Declaration factories capture metadata, bindings, and callback references eagerly;
|
||||||
construction errors remain attached for installation to report. Capture the
|
construction errors remain attached for installation to report. Capture the
|
||||||
metadata collections needed to keep later edits to a source declaration from
|
metadata collections needed to keep later edits to a source declaration from
|
||||||
changing a returned command. Application callback objects retain their ownership.
|
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
|
Validate each installation batch before mutating the instance registry. A failed
|
||||||
construction yields no usable application; a failed later registration leaves the
|
construction yields no usable application; a failed later registration leaves the
|
||||||
existing registry intact. Separate successful registration calls are not one
|
existing registry intact. Separate successful registration calls are not one
|
||||||
transaction. Duplicate paths and attempts to replace reserved commands report
|
transaction. Duplicate canonical paths and attempts to replace reserved commands
|
||||||
errors rather than letting registration order select a winner.
|
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
|
Explicit absent-command declarations contribute no commands, help entries, or
|
||||||
namespace claims; attached errors are reported before skipping absence. An invalid
|
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
|
integration practices. Independent consumer use of other CLI frameworks remains
|
||||||
application-owned.
|
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
|
## Handler Inputs And Invocation State
|
||||||
|
|
||||||
Handlers receive typed input structs with explicit flag and operand bindings.
|
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.
|
Standard contracts such as `context.Context` and `io.Writer` remain appropriate.
|
||||||
Upstream public types may be selected as deliberate dependencies in the
|
Upstream public types may be selected as deliberate dependencies in the
|
||||||
integrations that own them; that permission does not justify optional library
|
integrations that own them; that permission does not justify optional library
|
||||||
dependencies in the core. Core diagnostics use a CommandKit-owned severity type,
|
dependencies in the core. Core owns shared presentation categories,
|
||||||
and integrations convert upstream diagnostics while preserving selected outcome
|
and integrations adapt upstream diagnostics while preserving selected outcome
|
||||||
and error semantics. Application-owned input types remain application-owned.
|
and error semantics. Application-owned input types remain application-owned.
|
||||||
Expose narrow core composition contracts for demonstrated integration needs,
|
Expose narrow core composition contracts for demonstrated integration needs,
|
||||||
without exporting private assembly or backend structures.
|
without exporting private assembly or backend structures.
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ additional release requirements.
|
|||||||
CommandKit independently versions three source-only Go modules: the root core,
|
CommandKit independently versions three source-only Go modules: the root core,
|
||||||
`integrations/ckconfig`, and `integrations/ckprompt`. Standard CLI behavior is
|
`integrations/ckconfig`, and `integrations/ckprompt`. Standard CLI behavior is
|
||||||
exposed by consuming applications; CommandKit does not declare binary
|
exposed by consuming applications; CommandKit does not declare binary
|
||||||
deliverables or a cross-platform build matrix. ReleaseKit v0.4.2 is selected as
|
deliverables or a cross-platform build matrix. ReleaseKit v0.9.0 is selected as
|
||||||
the maintainer publication tool. The [release procedure](../release.md) owns
|
the maintainer publication tool. Maintainers may separately deliver page-only
|
||||||
the selected-module workflow, release order, and repository-specific commands;
|
Gitea releases from tagged source; delivery has its own outcome and URL. The
|
||||||
|
[release procedure](../release.md) owns the selected-module workflow, release
|
||||||
|
order, and repository-specific commands;
|
||||||
the integration adds no application command family or runtime ReleaseKit
|
the integration adds no application command family or runtime ReleaseKit
|
||||||
import.
|
import.
|
||||||
|
|
||||||
|
|||||||
132
docs/release.md
132
docs/release.md
@@ -3,10 +3,11 @@
|
|||||||
## Scope and release order
|
## Scope and release order
|
||||||
|
|
||||||
CommandKit publishes source-only Go module releases. Each module has an
|
CommandKit publishes source-only Go module releases. Each module has an
|
||||||
independent version and immutable annotated tag. This procedure publishes no
|
independent version and immutable annotated tag. A successful source-tag push
|
||||||
hosted release page, binaries, checksums, or other delivery assets. A successful
|
completes the source release. A separate local delivery command can publish a
|
||||||
source-tag push is release completion; CI, module proxy availability, and
|
Gitea release page; there are no binaries,
|
||||||
consumer installation are not completion gates.
|
checksums, or other delivery assets. CI, module proxy availability, and consumer
|
||||||
|
installation are not completion gates.
|
||||||
|
|
||||||
Publish in dependency order: core, then `ckconfig`, then `ckprompt`. The
|
Publish in dependency order: core, then `ckconfig`, then `ckprompt`. The
|
||||||
integration modules require the preceding CommandKit releases, so prepare and
|
integration modules require the preceding CommandKit releases, so prepare and
|
||||||
@@ -15,26 +16,28 @@ release runs only the root module's default suite; it need not run suites for
|
|||||||
independently released integrations.
|
independently released integrations.
|
||||||
|
|
||||||
Use branch `main`, remote `origin`, and expected effective push endpoint
|
Use branch `main`, remote `origin`, and expected effective push endpoint
|
||||||
`https://gitea.maximumdirect.net/eric/CommandKit.git`. The selected module profile
|
`ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git`. The selected
|
||||||
enforces the endpoint. The maintainer needs the selected
|
module profile enforces the endpoint. The maintainer needs the selected
|
||||||
[release toolchain](#release-toolchain), Git push access, and a POSIX shell.
|
[release toolchain](#release-toolchain), Git push access, and a POSIX shell.
|
||||||
This procedure follows the [release policy](policy/release.md); release-note
|
This procedure follows the [release policy](policy/release.md); release-note
|
||||||
footers follow the [OSS discoverability policy](policy/oss.md#discoverability).
|
footers follow the [OSS discoverability policy](policy/oss.md#discoverability).
|
||||||
|
|
||||||
ReleaseKit v0.4.2 owns candidate validation, publication guards, tag creation,
|
ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) owns candidate validation,
|
||||||
tag publication, and outcome reporting. Its pinned public contracts are the
|
publication guards, tag creation, tag publication, and outcome reporting.
|
||||||
[CLI reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/cli.md),
|
Its pinned public contracts are the
|
||||||
[profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/config.md),
|
[CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/cli.md),
|
||||||
and [publication guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/consumers/publish.md).
|
[profile reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/config.md),
|
||||||
|
the [publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/publish.md),
|
||||||
|
and the [delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/delivery.md).
|
||||||
The repository selects one default module suite per invocation. The launcher
|
The repository selects one default module suite per invocation. The launcher
|
||||||
bootstraps the native tool into ignored `.releasekit/` storage and delegates to
|
uses the root module’s registered tool with native host settings and read-only
|
||||||
its `publish` command.
|
module metadata.
|
||||||
|
|
||||||
## Release toolchain
|
## Release toolchain
|
||||||
|
|
||||||
The selected release toolchain is **Go 1.26.8**. Each module declares a consumer
|
The selected release toolchain is **Go 1.26.8**. Each module declares a consumer
|
||||||
minimum of **Go 1.26.0**. Maintainers use Go 1.26.8 for dependency preparation,
|
minimum of **Go 1.26.0**. Maintainers use Go 1.26.8 for dependency preparation,
|
||||||
candidate validation, and the ReleaseKit bootstrap. The
|
candidate validation, and ReleaseKit execution. The
|
||||||
[consumer guide](consumers/guide.md#go-compatibility) and
|
[consumer guide](consumers/guide.md#go-compatibility) and
|
||||||
[release policy](policy/release.md#consumer-minimum-and-release-toolchain)
|
[release policy](policy/release.md#consumer-minimum-and-release-toolchain)
|
||||||
describe these selections.
|
describe these selections.
|
||||||
@@ -66,14 +69,16 @@ its note explains the impact and consumer action. Never reuse a published
|
|||||||
version. Each release note starts with a product title and exact version, gives
|
version. Each release note starts with a product title and exact version, gives
|
||||||
a proportionate summary and compatibility guidance, and ends with immutable
|
a proportionate summary and compatibility guidance, and ends with immutable
|
||||||
links to that module's local grant, license, and attribution paths. The full tag
|
links to that module's local grant, license, and attribution paths. The full tag
|
||||||
in each URL includes the integration directory where applicable. Future tag
|
in each URL includes the integration directory where applicable. For future
|
||||||
URLs need not resolve before publication.
|
notes, use `https://forge.maximumdirect.net/base-layer/commandkit/src/tag/FULL_TAG/`
|
||||||
|
followed by the repository-relative path: `COPYRIGHT`, `LICENSE`, and
|
||||||
|
`docs/oss/attribution.md` for core, or those paths prefixed with the selected
|
||||||
|
integration directory. Future tag URLs need not resolve before publication.
|
||||||
|
|
||||||
All three release profiles explicitly select the repository-root `COPYRIGHT`,
|
All three release profiles explicitly select the repository-root `COPYRIGHT`,
|
||||||
`LICENSE`, and `docs/oss/attribution.md` through `validation.shared_files`.
|
`LICENSE`, and `docs/oss/attribution.md` through `validation.shared_files`.
|
||||||
ReleaseKit requires these committed files to exist in the candidate even when
|
ReleaseKit requires these committed files to exist in the candidate. The list
|
||||||
no optional file checks are enabled. The list does not enable content checks
|
does not expand Go validation scope; see the pinned [profile reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/config.md).
|
||||||
or expand Go validation scope; see the pinned [profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/b96262eb0ce9c8f2eb3e948a5841db2b799bc91b/docs/config.md).
|
|
||||||
|
|
||||||
Each integration also distributes its own `COPYRIGHT`, `LICENSE`, and
|
Each integration also distributes its own `COPYRIGHT`, `LICENSE`, and
|
||||||
`docs/oss/attribution.md`. These module-local materials are separate from the
|
`docs/oss/attribution.md`. These module-local materials are separate from the
|
||||||
@@ -82,10 +87,12 @@ Automatic module file inputs include applicable module-local files and stop at
|
|||||||
independent nested-module boundaries. Their inclusion does not itself enable
|
independent nested-module boundaries. Their inclusion does not itself enable
|
||||||
license or content validation.
|
license or content validation.
|
||||||
|
|
||||||
The current profiles enable no optional file checks or additional product
|
Each profile selects Go formatting, Markdown links, workspace-file and local
|
||||||
checks. Publication runs the selected module's standard offline suite and
|
replacement checks, and forbids the committed `.releasekit/gitea-token` path.
|
||||||
ReleaseKit's candidate/note and publication guards. The profiles select their
|
These upstream checks inspect committed inputs; module file selection stops at
|
||||||
physical module directory while using the same repository and endpoint.
|
independent module boundaries, while the forbidden path covers the repository.
|
||||||
|
They do not replace domain review. Publication also runs the selected module’s
|
||||||
|
standard offline suite and ReleaseKit’s candidate/note and publication guards.
|
||||||
|
|
||||||
## Prepare and publish one candidate
|
## Prepare and publish one candidate
|
||||||
|
|
||||||
@@ -99,39 +106,92 @@ future sums.
|
|||||||
|
|
||||||
Then commit the selected module's release note and all intended candidate
|
Then commit the selected module's release note and all intended candidate
|
||||||
changes on `main`, and publish the candidate commit to `origin`. Set one
|
changes on `main`, and publish the candidate commit to `origin`. Set one
|
||||||
selector, module directory, and version from the table above, then run:
|
module directory and version from the table above, then run the corresponding
|
||||||
|
publication command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
module_dir=.
|
module_dir=.
|
||||||
module_selector=core
|
|
||||||
module_version=v0.2.0
|
|
||||||
(cd "$module_dir" && GOWORK=off GOTOOLCHAIN=local go mod download)
|
(cd "$module_dir" && GOWORK=off GOTOOLCHAIN=local go mod download)
|
||||||
# Commit reviewed dependency changes and the selected module's note on main.
|
# Commit reviewed dependency changes and the selected module's note on main.
|
||||||
git push origin HEAD:refs/heads/main
|
git push origin HEAD:refs/heads/main
|
||||||
./scripts/release "$module_selector" "$module_version"
|
./scripts/release core vX.Y.Z
|
||||||
|
# For integrations use: ./scripts/release ckconfig vX.Y.Z
|
||||||
|
# or: ./scripts/release ckprompt vX.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
Run only the command for the selected module. Its profile runs the complete
|
Run only the command for the selected module. Its profile runs the complete
|
||||||
offline deterministic default suite once for that module, checks candidate and
|
offline deterministic default suite once for that module, checks candidate and
|
||||||
remote guards, creates the annotated tag from the matching note, and pushes
|
remote guards, creates the annotated tag from the matching note, and pushes
|
||||||
only that full tag. `check` is available for diagnosis and is not a required
|
only that full tag. `./scripts/releasekit core check vX.Y.Z` is an optional
|
||||||
pre-publication command. The launcher does not push `main`; publish the
|
diagnostic, not a required pre-publication step: publication repeats validation.
|
||||||
|
Use the relevant integration selector and its version when diagnosing that
|
||||||
|
module. Inspect the registered tool with `./scripts/releasekit version`. The
|
||||||
|
launcher does not push `main`; publish the
|
||||||
candidate commit first. Successful source publication does not wait for CI,
|
candidate commit first. Successful source publication does not wait for CI,
|
||||||
proxy availability, or downstream installation.
|
proxy availability, or downstream installation.
|
||||||
|
|
||||||
## Completion and ambiguous publication
|
## Completion and ambiguous publication
|
||||||
|
|
||||||
A successful push to the expected endpoint completes the source release. On an
|
A successful push to the expected endpoint completes the source release.
|
||||||
ambiguous result, use the exact endpoint, full tag, and annotated tag object
|
ReleaseKit also attempts to save private publication evidence under the
|
||||||
reported by that invocation. Confirm endpoint resolution has not changed, then
|
repository's Git common directory, shared by linked worktrees. It reports the
|
||||||
query only that exact tag ref. Compare the remote annotated tag-object ID with
|
saved record path or a recording warning separately from publication status;
|
||||||
the reported object: an exact match confirms publication, a different object is
|
a storage failure does not change publication status. Records can contain
|
||||||
a conflict, successful output without the ref proves absence at that endpoint,
|
credential-bearing endpoints. Retain them privately while recovery is needed
|
||||||
|
and remove them when their recovery value expires; retention is maintainer-owned.
|
||||||
|
|
||||||
|
On an ambiguous result, use the recovery command printed for that invocation's
|
||||||
|
saved record. To discover records and explicitly select the matching attempt,
|
||||||
|
the shared helper can also be used:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/releasekit publication-attempts
|
||||||
|
./scripts/releasekit reconcile --record /absolute/path/to/attempt.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the example record path with the saved path for the intended attempt.
|
||||||
|
Relative record paths resolve from the repository root.
|
||||||
|
Discovery lists local evidence without querying remotes or selecting an attempt.
|
||||||
|
Reconciliation observes the recorded endpoint, full tag, and tag object without
|
||||||
|
rerunning candidate validation or changing tags. It distinguishes confirmed
|
||||||
|
publication, current absence, conflict, and an unresolved observation. Current
|
||||||
|
absence does not prove that a version was never published. The pinned
|
||||||
|
[CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/cli.md#recover-a-saved-publication-attempt-or-discover-local-attempts)
|
||||||
|
owns recovery statuses and structured output; the
|
||||||
|
[publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/publish.md#retain-and-reconcile-publication-evidence)
|
||||||
|
describes record storage and recovery entry points.
|
||||||
|
|
||||||
|
If no usable record was saved, use the exact endpoint, full tag, and annotated
|
||||||
|
tag object reported by that invocation. Query only that exact tag ref at the
|
||||||
|
captured endpoint. An exact tag-object match confirms publication, a different
|
||||||
|
object is a conflict, successful output without the ref shows current absence,
|
||||||
and a failed query leaves the result unresolved. Do not infer the captured
|
and a failed query leaves the result unresolved. Do not infer the captured
|
||||||
endpoint's state from another remote or a rewritten URL.
|
endpoint's state from another remote or a rewritten URL. Resolve unknown
|
||||||
|
publication state before retrying.
|
||||||
|
|
||||||
ReleaseKit may retain a local tag after a failed push. Remove an unpublished
|
ReleaseKit may retain a local tag after a failed push. Remove an unpublished
|
||||||
local tag only after proving absence at the captured endpoint and confirming
|
local tag only after proving absence at the captured endpoint and confirming
|
||||||
that the tag was never published or used by another release channel. Never
|
that the tag was never published or used by another release channel. Never
|
||||||
move, overwrite, recreate, or delete a published tag. Resolve conflicts with a
|
move, overwrite, recreate, or delete a published tag. Resolve conflicts with a
|
||||||
fresh version; corrections to published source require a new version.
|
fresh version; corrections to published source require a new version.
|
||||||
|
|
||||||
|
## Hosted delivery
|
||||||
|
|
||||||
|
After source publication, check out the original tagged source with a clean
|
||||||
|
worktree and index, then deliver the selected module's page:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/releasekit core deliver vX.Y.Z
|
||||||
|
# Or use ckconfig or ckprompt with that module's semantic version.
|
||||||
|
```
|
||||||
|
|
||||||
|
The profile derives the full module-specific tag from the version. Delivery
|
||||||
|
uses the checked-in release note as a page-only body and reports its own outcome
|
||||||
|
and URL separately from source publication. Retry later from the same original
|
||||||
|
tagged source. The pinned [delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/delivery.md)
|
||||||
|
owns delivery and retry behavior.
|
||||||
|
|
||||||
|
A nonblank `RELEASEKIT_GITEA_TOKEN` takes precedence over the repository-relative
|
||||||
|
`.releasekit/gitea-token` file. Keep the credential directory ignored and retain
|
||||||
|
its local contents. Credentials and Gitea availability do not gate source
|
||||||
|
publication.
|
||||||
|
|||||||
88
docs/releases/v0.3.0.md
Normal file
88
docs/releases/v0.3.0.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# CommandKit v0.3.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
CommandKit v0.3.0 moves core to `go.maximumdirect.net/commandkit`, adds
|
||||||
|
application-owned command and flag aliases, shared invocation reporting, and
|
||||||
|
shell completion. 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.
|
||||||
|
- Install the optional `Completion` command and declare static choices or
|
||||||
|
path hints for interactive shell completion. Bash, Zsh, and Fish scripts
|
||||||
|
use the application's captured commands and routing metadata.
|
||||||
|
- Maintainer release tooling now uses the root-selected ReleaseKit v0.9.0 tool,
|
||||||
|
with separate source publication and optional page-only hosted delivery.
|
||||||
|
|
||||||
|
## Compatibility and migration
|
||||||
|
|
||||||
|
Change core imports from `gitea.maximumdirect.net/eric/CommandKit` to
|
||||||
|
`go.maximumdirect.net/commandkit` and select v0.3.0. These paths identify
|
||||||
|
different Go packages, so their types cannot be mixed. Applications using the
|
||||||
|
integrations should migrate together to the forthcoming matched
|
||||||
|
`go.maximumdirect.net/ckconfig v0.2.0` and
|
||||||
|
`go.maximumdirect.net/ckprompt v0.2.0` releases, which depend on the new core;
|
||||||
|
the existing integration releases retain their old identities.
|
||||||
|
|
||||||
|
`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://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/consumers/guide.md),
|
||||||
|
[CLI reference](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/cli.md),
|
||||||
|
and [maintainer release procedure](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/oss/attribution.md)
|
||||||
@@ -25,12 +25,28 @@ unselected extensions to the delivered ConfigKit comparison. Consider them only
|
|||||||
for a concrete consumer need and a deliberate API/behavior decision under the
|
for a concrete consumer need and a deliberate API/behavior decision under the
|
||||||
relevant ADRs.
|
relevant ADRs.
|
||||||
|
|
||||||
|
### Alias completion and discovery
|
||||||
|
|
||||||
|
The delivered [shell completion feature](../cli.md#shell-completion) provides
|
||||||
|
canonical suggestions and alias-aware interpretation. Potential future work
|
||||||
|
includes suggesting command and flag alias spellings and valid continuations of
|
||||||
|
alias-only prefixes. For example, completion could suggest `inspect` after
|
||||||
|
`app legacy ` when `legacy inspect` is an installed alias.
|
||||||
|
|
||||||
|
Alias discovery is outside the initial scope. This deferral does not establish a
|
||||||
|
permanent canonical-only completion contract or prohibit future alias discovery.
|
||||||
|
A later design should determine how alias suggestions are presented alongside
|
||||||
|
canonical suggestions while ensuring inserted continuations form valid next
|
||||||
|
steps. No delivery date or final API is selected here.
|
||||||
|
|
||||||
## Other deferred proposals
|
## Other deferred proposals
|
||||||
|
|
||||||
These capabilities remain outside the delivered scope and require a concrete
|
These capabilities remain outside the delivered scope and require a concrete
|
||||||
operator or consumer need and an appropriate ownership/behavior decision:
|
operator or consumer need and an appropriate ownership/behavior decision:
|
||||||
|
|
||||||
- JSON reports, shell completion, and output-file management or reloadable export.
|
- JSON reports and output-file management or reloadable export.
|
||||||
|
- Shell completion extensions, including alias discovery described above and
|
||||||
|
dynamic discovery of configured or remote values.
|
||||||
- Configuration mutation, generic value assignment, and exhaustive field-origin
|
- Configuration mutation, generic value assignment, and exhaustive field-origin
|
||||||
inventories.
|
inventories.
|
||||||
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
||||||
|
|||||||
343
docs/roadmap/implementation.md
Normal file
343
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
# CommandKit vanity migration and dependency alignment plan
|
||||||
|
|
||||||
|
Status: Stage 1 complete; Stages 2–7 are not started.
|
||||||
|
|
||||||
|
This plan is for the Luna coding agent implementing the
|
||||||
|
[feature roadmap](integration-dependency-alignment.md). Follow the stages in
|
||||||
|
integer order during one end-to-end sprint. Unpublished editing states may be
|
||||||
|
temporarily inconsistent. Each published module must be a coherent, committed
|
||||||
|
candidate validated by its ordinary release workflow.
|
||||||
|
|
||||||
|
This planning pass changes documentation only. The target includes three real
|
||||||
|
source releases because later modules need downloadable predecessors. The
|
||||||
|
publication stages below describe that future work; this planning request does
|
||||||
|
not perform or authorize Git pushes. Honor the user's implementation/publication
|
||||||
|
scope when executing the plan, and report any unperformed release as pending.
|
||||||
|
|
||||||
|
Read [development guidance](../development.md), the
|
||||||
|
[agent policy](../policy/agents.md), [architecture policy](../policy/architecture.md),
|
||||||
|
[testing policy](../policy/testing.md),
|
||||||
|
[documentation policy](../policy/documentation.md),
|
||||||
|
[release policy](../policy/release.md), and [OSS policy](../policy/oss.md) for
|
||||||
|
the work they govern. Follow `AGENTS.md` codebase-memory discovery instructions.
|
||||||
|
The [release procedure](../release.md) remains the owner of operational commands.
|
||||||
|
|
||||||
|
The previous plan's [completed ReleaseKit v0.9.0 stages and evidence](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/c8d659dfe5ae3e834b3aeabdfd0fe29b736dc05a/docs/roadmap/implementation.md)
|
||||||
|
remain in Git history. R1/R2 in [remediation.md](remediation.md) and the ReleaseKit
|
||||||
|
adoption are complete; do not restart them or rewrite their historical evidence.
|
||||||
|
|
||||||
|
## Baseline and fixed decisions
|
||||||
|
|
||||||
|
Planning baseline: `c8d659dfe5ae3e834b3aeabdfd0fe29b736dc05a`.
|
||||||
|
|
||||||
|
| Module directory | Current module path | Target module path | Target version and full tag |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `.` | `gitea.maximumdirect.net/eric/CommandKit` | `go.maximumdirect.net/commandkit` | v0.3.0; `v0.3.0` |
|
||||||
|
| `integrations/ckconfig` | `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | `go.maximumdirect.net/ckconfig` | v0.2.0; `integrations/ckconfig/v0.2.0` |
|
||||||
|
| `integrations/ckprompt` | `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | `go.maximumdirect.net/ckprompt` | v0.2.0; `integrations/ckprompt/v0.2.0` |
|
||||||
|
|
||||||
|
- Both integrations currently require core v0.2.0; ckprompt also requires
|
||||||
|
ckconfig v0.1.0. Their source uses newer core APIs. Independent baseline suites
|
||||||
|
fail; temporary local-source selections pass only when ckprompt also uses
|
||||||
|
current ckconfig. The feature roadmap records those diagnostics.
|
||||||
|
- Target ckconfig requires `go.maximumdirect.net/commandkit v0.3.0`. Target
|
||||||
|
ckprompt requires that core and `go.maximumdirect.net/ckconfig v0.2.0`.
|
||||||
|
- Vanity discovery is verified. The integration metadata's fourth field maps
|
||||||
|
the short paths to the existing physical directories. Existing tags still
|
||||||
|
declare old identities. Discovery alone does not complete module migration.
|
||||||
|
- Preserve package names, exported declaration shapes, runtime behavior, Go
|
||||||
|
1.26.0 consumer minimums, and the Go 1.26.8 maintainer toolchain. Preserve root
|
||||||
|
ReleaseKit v0.9.0 selection, scripts, profiles, endpoints, and validators.
|
||||||
|
- Retain ConfigKit v0.8.0 and PromptKit v0.16.0; no upstream library upgrade is
|
||||||
|
selected. Inspect tidy's actual graph changes and update affected attribution.
|
||||||
|
- The three proposed tags were unused at planning time. Existing publication
|
||||||
|
guards recheck freshness; never move/reuse a published tag. A conflict needs
|
||||||
|
a deliberate new version selection and matching dependent metadata/notes.
|
||||||
|
- Retain ignored `go.work` and `go.work.sum`, and the supplied ignored
|
||||||
|
`.releasekit/gitea-token` without reading its contents. No credential or live
|
||||||
|
provider is needed for local validation. Source publication uses the existing
|
||||||
|
Git endpoint; hosted delivery remains a separate operation/outcome.
|
||||||
|
|
||||||
|
Do not rename physical module directories or flatten integration tag prefixes.
|
||||||
|
Do not add an old-path forwarding module, type-interoperability layer, API
|
||||||
|
rollback, permanent validation shim, or new recurring release gate. These are
|
||||||
|
outside the selected migration, not new restrictions on application behavior.
|
||||||
|
|
||||||
|
## Execution and validation conventions
|
||||||
|
|
||||||
|
Use Go 1.26.8 on `PATH` following the existing
|
||||||
|
[toolchain setup](../release.md#release-toolchain). The ignored workspace declares
|
||||||
|
Go 1.26.10 at baseline; preserve it. Use `GOWORK=off GOTOOLCHAIN=local` for module
|
||||||
|
preparation and independent validation. Use normal dependency retrieval during
|
||||||
|
preparation; ReleaseKit runs the final selected-module suite offline/read-only.
|
||||||
|
|
||||||
|
Migrate module-owned source, tests, and examples together. Apply integration
|
||||||
|
path mappings before the broader core prefix when a replacement tool is used;
|
||||||
|
`go.maximumdirect.net/commandkit/integrations/ckconfig` is not the selected
|
||||||
|
ckconfig identity. Preserve import aliases such as `commandkit`. Core `internal`
|
||||||
|
imports must use the new core prefix. Review identity-sensitive strings as well
|
||||||
|
as Go import declarations.
|
||||||
|
|
||||||
|
Update current prose and GoDoc for the module being migrated. Treat repository,
|
||||||
|
license, and immutable historical source links separately from Go import paths.
|
||||||
|
Keep published notes, accepted ADR history, and completed review evidence intact.
|
||||||
|
Do not globally replace every old URL or migrate unrelated upstream identities.
|
||||||
|
|
||||||
|
Publication steps use the existing protocol: prepare dependencies, review and
|
||||||
|
commit the final selected candidate on `main`, publish that candidate commit to
|
||||||
|
the authoritative branch, and invoke the module's release wrapper. The wrapper
|
||||||
|
runs the owning complete suite once, selected file checks, and publication
|
||||||
|
guards, then creates/pushes the intended tag. It does not push `main` for you.
|
||||||
|
Run validation-only `check` for diagnosis when useful; do not make it a required
|
||||||
|
duplicate suite before `publish`. Do not add CI/proxy polling or post-push
|
||||||
|
read-back as a successful-publication gate. Reuse evidence for unchanged content.
|
||||||
|
|
||||||
|
Each release has its own candidate commit, suite result, and publication outcome.
|
||||||
|
The next module's dependency download is preparation for that next release;
|
||||||
|
failure to download does not undo completion of the predecessor's source push.
|
||||||
|
Use a command-scoped direct download to diagnose proxy lag if necessary rather
|
||||||
|
than permanently changing proxy settings. Retain authentic downloaded sums.
|
||||||
|
|
||||||
|
## Stage 1: Migrate core identity and prepare its release candidate
|
||||||
|
|
||||||
|
**Completed.** The root module, owned imports, component build identity,
|
||||||
|
consumer guidance, and v0.3.0 release note use the new core identity. The
|
||||||
|
focused build-identity test passed offline with Go 1.26.8; affected links and
|
||||||
|
whitespace were checked. The complete candidate suite belongs to publication.
|
||||||
|
|
||||||
|
1. Change only the root module declaration to `go.maximumdirect.net/commandkit`.
|
||||||
|
Migrate root-owned imports in production files, root/external tests, internal
|
||||||
|
packages, and `examples/core`. Leave nested module migration to Stages 3/5.
|
||||||
|
Preserve the root tool declaration and other dependency versions; prepare
|
||||||
|
caches and tidy the root with workspace discovery off.
|
||||||
|
2. Update `commandKitModulePath` in `builtins.go` to the new core identity.
|
||||||
|
`version --scope commandkit` uses it to find build information. Retain its
|
||||||
|
existing application/component scopes, tag/replacement handling, and `dev`
|
||||||
|
fallback; do not add a legacy-identity fallback.
|
||||||
|
3. Adapt existing identity/import-path test expectations. In
|
||||||
|
`TestCommandKitBuildIdentityProjectsEffectiveModuleVersion`, make at least
|
||||||
|
one existing successful tagged case supply the literal new module path
|
||||||
|
independently of `commandKitModulePath`. Otherwise a stale constant and its
|
||||||
|
self-referencing fixtures could agree while real version lookup fails.
|
||||||
|
Preserve all existing replacement/development cases and boundary assertions.
|
||||||
|
4. Update the root row in README's module table, current core imports and
|
||||||
|
installation guidance in `docs/consumers/guide.md`, affected developer and
|
||||||
|
internal guidance, example documentation, and GoDoc. Describe integrations
|
||||||
|
according to their implemented identity until their migration stages land;
|
||||||
|
keep the matched future set in migration guidance explicit.
|
||||||
|
5. Refresh `docs/releases/v0.3.0.md` against the full v0.2.0-to-candidate change:
|
||||||
|
aliases, reporting migration, completion, ReleaseKit maintenance, and the new
|
||||||
|
core import path. Explain consumer updates and the forthcoming matched
|
||||||
|
integration versions. Use current procedure URLs with immutable `v0.3.0`
|
||||||
|
source/licensing links. Keep the declared toolchains unchanged.
|
||||||
|
6. Format changed Go files. Review root-owned imports/identity literals and
|
||||||
|
module metadata; check affected prose links and whitespace. Run the existing
|
||||||
|
focused identity test with prepared caches:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOWORK=off GOTOOLCHAIN=local GOPROXY=off GONOPROXY=none GOSUMDB=off \
|
||||||
|
go test -mod=readonly -count=1 -run '^TestCommandKitBuildIdentityProjectsEffectiveModuleVersion$' .
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stage result:** a reviewed core v0.3.0 candidate with the new identity, correct
|
||||||
|
component version lookup, migrated root consumers, and current release notes.
|
||||||
|
The full root suite belongs to Stage 2's normal publication workflow.
|
||||||
|
|
||||||
|
## Stage 2: Validate and publish core v0.3.0
|
||||||
|
|
||||||
|
**Status:** not started.
|
||||||
|
|
||||||
|
1. Review and commit the complete core candidate under the local commit policy.
|
||||||
|
Confirm that unrelated/private files and ignored workspaces are excluded.
|
||||||
|
The independently released integration sources may still declare old paths;
|
||||||
|
that does not make their suites a prerequisite for this root release.
|
||||||
|
2. From the repository root, execute the existing publication sequence within
|
||||||
|
the authorized release scope:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push origin HEAD:refs/heads/main
|
||||||
|
./scripts/release core v0.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Require the real selected-module suite and file checks to pass. Fix actual
|
||||||
|
failures in scope and commit the corrected candidate before retrying. On a
|
||||||
|
failed/ambiguous push, use the existing publication evidence and recovery
|
||||||
|
procedure. Do not force refs, bypass guards, or treat a local tag as publication.
|
||||||
|
4. Record the candidate commit, module path, full tag, validation outcome, and
|
||||||
|
confirmed source outcome here. A successful tag push completes this source
|
||||||
|
release; move to dependent preparation without waiting for hosted pages.
|
||||||
|
|
||||||
|
**Stage result:** core v0.3.0 is source-published with
|
||||||
|
`module go.maximumdirect.net/commandkit`. Actual download of this version and its
|
||||||
|
sums is part of Stage 3, not a new completion gate for core publication.
|
||||||
|
|
||||||
|
## Stage 3: Migrate ckconfig and prepare its independent candidate
|
||||||
|
|
||||||
|
**Status:** not started.
|
||||||
|
|
||||||
|
1. In `integrations/ckconfig`, migrate the module declaration to
|
||||||
|
`go.maximumdirect.net/ckconfig`. Update all owned imports, tests, and the
|
||||||
|
maintained simple example to that self-path and the new core path. Keep
|
||||||
|
package names and existing adapter behavior.
|
||||||
|
2. Replace its old core dependency key with the published new-path version.
|
||||||
|
From the ckconfig module directory, use:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOWORK=off GOTOOLCHAIN=local go mod edit -module=go.maximumdirect.net/ckconfig -droprequire=gitea.maximumdirect.net/eric/CommandKit -require=go.maximumdirect.net/commandkit@v0.3.0
|
||||||
|
GOWORK=off GOTOOLCHAIN=local go mod download go.maximumdirect.net/commandkit@v0.3.0
|
||||||
|
GOWORK=off GOTOOLCHAIN=local go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Perform source import migration before tidy to avoid resolving old imports.
|
||||||
|
Keep real downloaded sums. If retrieval is unavailable, resolve dependency
|
||||||
|
preparation before proceeding; do not fabricate metadata or substitute a
|
||||||
|
local replacement as final release evidence.
|
||||||
|
3. Inspect the actual direct and indirect graph, including tool requirements.
|
||||||
|
Preserve ConfigKit v0.8.0 and unrelated versions. Update affected notices in
|
||||||
|
`integrations/ckconfig/docs/oss/attribution.md` under the OSS policy using the
|
||||||
|
actual selected source revisions; add/retain applicable license texts. Do
|
||||||
|
not assume similarly named old/new upstream modules are interchangeable.
|
||||||
|
4. Prepare `integrations/ckconfig/docs/releases/v0.2.0.md` with product
|
||||||
|
`CommandKit ckconfig`, the new module/core paths, compatible dependency set,
|
||||||
|
and relevant reporting/completion migration. Immutable footer/source URLs
|
||||||
|
use the full tag `integrations/ckconfig/v0.2.0` and correct physical files.
|
||||||
|
5. Update README's ckconfig row, consumer module/import examples, the simple
|
||||||
|
example guidance, affected GoDoc, and relevant current release/development
|
||||||
|
references. Explain that applications moving to the new core identity also
|
||||||
|
use the matched new integration identity.
|
||||||
|
6. Format changed Go files. Review for stale owned imports/direct requirement
|
||||||
|
keys, accidental replacements/workspace files, and unexplained graph churn.
|
||||||
|
Check current links and whitespace. The owning complete suite runs in
|
||||||
|
Stage 4; a local workspace pass is not a substitute.
|
||||||
|
|
||||||
|
**Stage result:** a ckconfig v0.2.0 candidate whose actual dependency is
|
||||||
|
downloaded `go.maximumdirect.net/commandkit v0.3.0`, with authentic metadata and
|
||||||
|
aligned current documentation/attribution.
|
||||||
|
|
||||||
|
## Stage 4: Validate and publish ckconfig v0.2.0
|
||||||
|
|
||||||
|
**Status:** not started.
|
||||||
|
|
||||||
|
Review and commit the complete ckconfig candidate. From the repository root,
|
||||||
|
use the same authoritative-branch protocol as Stage 2:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push origin HEAD:refs/heads/main
|
||||||
|
./scripts/release ckconfig v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The normal release command must pass the independent ckconfig suite, including
|
||||||
|
its maintained example, with workspace discovery off and without dependency
|
||||||
|
redirection. It must also pass selected committed-file checks and publication
|
||||||
|
guards. Do not insert the earlier temporary Go shim or weaken a selected check.
|
||||||
|
Apply the existing failed/ambiguous-publication procedure if needed.
|
||||||
|
|
||||||
|
Record the candidate, path, version, full tag, validation, and source outcome.
|
||||||
|
The intended tag is `integrations/ckconfig/v0.2.0`; the physical directory remains
|
||||||
|
`integrations/ckconfig` despite the short vanity module path.
|
||||||
|
|
||||||
|
**Stage result:** compatible ckconfig v0.2.0 is source-published under its vanity
|
||||||
|
identity. Downloading it for ckprompt is the next module's preparation.
|
||||||
|
|
||||||
|
## Stage 5: Migrate ckprompt and prepare its independent candidate
|
||||||
|
|
||||||
|
**Status:** not started.
|
||||||
|
|
||||||
|
1. Migrate `integrations/ckprompt` to `go.maximumdirect.net/ckprompt`. Update its
|
||||||
|
owned source/tests and composed example to import the new core, ckconfig,
|
||||||
|
and ckprompt paths. Preserve PromptKit adaptation and reporting behavior.
|
||||||
|
2. From that module directory, replace both old CommandKit dependency keys:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOWORK=off GOTOOLCHAIN=local go mod edit -module=go.maximumdirect.net/ckprompt -droprequire=gitea.maximumdirect.net/eric/CommandKit -droprequire=gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig -require=go.maximumdirect.net/commandkit@v0.3.0 -require=go.maximumdirect.net/ckconfig@v0.2.0
|
||||||
|
GOWORK=off GOTOOLCHAIN=local go mod download go.maximumdirect.net/commandkit@v0.3.0 go.maximumdirect.net/ckconfig@v0.2.0
|
||||||
|
GOWORK=off GOTOOLCHAIN=local go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrate imports before tidy. Retain actual downloaded sums; resolve fetch
|
||||||
|
failures as preparation rather than masking them with local source. A new
|
||||||
|
core with old ckconfig v0.1.0 is the known incompatible combination.
|
||||||
|
3. Review graph changes while retaining ConfigKit v0.8.0 and PromptKit v0.16.0.
|
||||||
|
Update affected module-local attribution and immutable notice links based on
|
||||||
|
the resolved graph, including transitive tools. Keep actual upstream module
|
||||||
|
identities distinct from the three CommandKit identities being migrated.
|
||||||
|
4. Prepare `integrations/ckprompt/docs/releases/v0.2.0.md` with product
|
||||||
|
`CommandKit ckprompt`, new imports/dependencies, and consumer migration.
|
||||||
|
Footer/source links use full tag `integrations/ckprompt/v0.2.0` and physical
|
||||||
|
module-local files. Preserve the published v0.1.0 record.
|
||||||
|
5. Update README's ckprompt row, consumer composition/import examples, composed
|
||||||
|
example guidance, affected GoDoc, and remaining current module descriptions.
|
||||||
|
Keep URLs to hosted repository files separate from Go package links.
|
||||||
|
6. Format changed Go files and review remaining owned old-path imports or direct
|
||||||
|
requirement keys, metadata, links, and whitespace. Keep the ordinary source
|
||||||
|
checks intact; the independent suite runs in Stage 6.
|
||||||
|
|
||||||
|
**Stage result:** ckprompt v0.2.0 composes the real new-path core and ckconfig
|
||||||
|
versions in its declared graph, with current guidance and authentic metadata.
|
||||||
|
|
||||||
|
## Stage 6: Validate and publish ckprompt v0.2.0
|
||||||
|
|
||||||
|
**Status:** not started.
|
||||||
|
|
||||||
|
Review and commit the complete ckprompt candidate. From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push origin HEAD:refs/heads/main
|
||||||
|
./scripts/release ckprompt v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The normal publication workflow must pass the independent ckprompt suite and
|
||||||
|
composed consumer with actual downloaded predecessors, plus selected file and
|
||||||
|
publication checks. Preserve the offline/read-only suite environment and
|
||||||
|
ordinary recovery handling. No workspace or replacement shim supplies final
|
||||||
|
release evidence.
|
||||||
|
|
||||||
|
Record its candidate, module identity, full tag
|
||||||
|
`integrations/ckprompt/v0.2.0`, suite outcome, and source-publication outcome.
|
||||||
|
Report any separate hosted delivery under its own outcome; page publication
|
||||||
|
is not required to establish dependency alignment.
|
||||||
|
|
||||||
|
**Stage result:** all three matched source releases are published with vanity
|
||||||
|
module declarations and verified independent dependency graphs.
|
||||||
|
|
||||||
|
## Stage 7: Audit the completed migration and record evidence
|
||||||
|
|
||||||
|
**Status:** not started.
|
||||||
|
|
||||||
|
1. Review the cumulative diff against the feature roadmap. Confirm all three
|
||||||
|
module declarations, owned imports, examples, direct dependency keys, and
|
||||||
|
component build identity use the new paths. Preserve physical directories,
|
||||||
|
tag prefixes, root ReleaseKit selection, scripts/profiles, exported shapes,
|
||||||
|
Go minimums, and upstream versions apart from explained graph consequences.
|
||||||
|
2. Check that tests and current guidance use the new identities while historical
|
||||||
|
notes, accepted ADRs, and immutable source URLs retain their historical
|
||||||
|
meaning. Resolve current dependency-readiness claims that are now stale.
|
||||||
|
Do not mechanically rewrite every old-path reference in completed records.
|
||||||
|
3. Record each final candidate's exact commit, full tag, suite result, and
|
||||||
|
publication outcome. Identify diagnostic-only earlier evidence separately.
|
||||||
|
Reuse each successful publication suite for its unchanged candidate; do not
|
||||||
|
require another complete run just to finish this stage. Any later code or
|
||||||
|
metadata correction belongs in a new candidate/version under release policy.
|
||||||
|
4. Mark stages complete only with their stated evidence and update the feature
|
||||||
|
roadmap's status/link here. Preserve this record and prior immutable plans;
|
||||||
|
apply normal roadmap retirement only during later preparation. If publication
|
||||||
|
was outside the authorized implementation scope or could not finish, record
|
||||||
|
the actual prepared state and pending release stages rather than claiming
|
||||||
|
alignment is complete.
|
||||||
|
5. Review new evidence prose, links/fragments, intended staged files, and
|
||||||
|
`git diff --check` / `git diff --cached --check`. Commit any remaining
|
||||||
|
nonempty documentation unit with a short plain-English message, following
|
||||||
|
the local commit policy. Finish with a clean tracked worktree when only
|
||||||
|
planned work is present; preserve unrelated and ignored local files.
|
||||||
|
|
||||||
|
**Stage result:** the implemented and published matched version set has accurate
|
||||||
|
source/validation evidence, current migration guidance, and committed tracking.
|
||||||
|
Source release and hosted-delivery outcomes remain separate.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None required to choose the implementation. Vanity paths, target versions,
|
||||||
|
physical layout, dependency order, validation ownership, and compatibility scope
|
||||||
|
are selected in the roadmap. Publication authorization is an execution-scope
|
||||||
|
constraint, not a reason to invent different versions, fabricated sums, or a
|
||||||
|
local-only substitute for the target end state.
|
||||||
208
docs/roadmap/integration-dependency-alignment.md
Normal file
208
docs/roadmap/integration-dependency-alignment.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Integration dependency alignment and vanity-path migration
|
||||||
|
|
||||||
|
Status: proposed release-preparation roadmap. This pass adds documentation only;
|
||||||
|
dependency changes and publication remain future work. The
|
||||||
|
[implementation plan](implementation.md) owns execution stages, concrete tasks,
|
||||||
|
and validation tracking; this roadmap owns the target and selected scope.
|
||||||
|
|
||||||
|
## Purpose and authority
|
||||||
|
|
||||||
|
Align the independently released integrations with the core APIs their current
|
||||||
|
source already uses, and migrate all three CommandKit modules to the supplied
|
||||||
|
vanity paths. Publish compatible module versions in dependency order so each
|
||||||
|
integration builds and validates against its declared, downloaded dependencies
|
||||||
|
with workspace discovery off.
|
||||||
|
|
||||||
|
The [release procedure](../release.md) owns publication commands and release
|
||||||
|
order. The [release policy](../policy/release.md),
|
||||||
|
[agent policy](../policy/agents.md), [testing policy](../policy/testing.md),
|
||||||
|
[documentation policy](../policy/documentation.md), and
|
||||||
|
[OSS policy](../policy/oss.md) govern the work. This roadmap selects the target
|
||||||
|
and release-preparation scope; it does not change those policies.
|
||||||
|
|
||||||
|
## Baseline and investigation evidence
|
||||||
|
|
||||||
|
Reviewed baseline: `289a171e97699ad9aa57ee2cc40bdad1c0b680a9`.
|
||||||
|
Vanity-path review baseline: `d7cbaed3ed18bc2909c8144f308e2dfd58ade7ec`.
|
||||||
|
Module declarations and library source were unchanged between these revisions.
|
||||||
|
|
||||||
|
- Both integration module declarations require core v0.2.0. The
|
||||||
|
[ckprompt declaration](../../integrations/ckprompt/go.mod) also requires
|
||||||
|
ckconfig v0.1.0; the [ckconfig declaration](../../integrations/ckconfig/go.mod)
|
||||||
|
has no dependency on ckprompt.
|
||||||
|
- Current integration source uses newer core APIs including `CompletionHint`,
|
||||||
|
`Explanation`, and `Reporter`. Both independent Go 1.26.8 suites fail to
|
||||||
|
compile with `GOWORK=off` and their existing read-only module graphs.
|
||||||
|
- Updating core alone is insufficient for ckprompt: published ckconfig v0.1.0
|
||||||
|
still uses removed core APIs, including `Invocation.Streams`,
|
||||||
|
`Outcome.Diagnostics`, and `SeverityError`, plus the old `Failure(error)`
|
||||||
|
call shape.
|
||||||
|
- The authoritative `origin` tag listing contains core v0.1.0 and v0.2.0, and
|
||||||
|
the two integration v0.1.0 tags. The proposed next versions below were unused
|
||||||
|
at review. Remote `main` was `230187296fae5253cb1773f16c2f1d1a39499ed0`;
|
||||||
|
the reviewed local changes still need normal publication to the authoritative
|
||||||
|
branch as part of candidate preparation.
|
||||||
|
- A checked-in [core v0.3.0 draft](../releases/v0.3.0.md) already describes the
|
||||||
|
reporting and alias migration. It is an unpublished draft, not proof that
|
||||||
|
the module version can be downloaded.
|
||||||
|
|
||||||
|
The investigation used disposable alternate module files, Go 1.26.8,
|
||||||
|
`GOWORK=off`, and read-only offline tests. Only temporary dependency selections
|
||||||
|
changed; the real module declarations, sums, and source remained unchanged.
|
||||||
|
|
||||||
|
| Current source under test | Temporary dependency selection | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ckconfig | Current core source | Module suite and maintained example passed. |
|
||||||
|
| ckprompt | Current core source, published ckconfig v0.1.0 | Compilation failed in the old ckconfig implementation. |
|
||||||
|
| ckprompt | Current core and current ckconfig source | Module suite and maintained example passed. |
|
||||||
|
|
||||||
|
The successful cases retained ConfigKit v0.8.0 and PromptKit v0.16.0. These
|
||||||
|
results isolate the CommandKit dependency alignment needed here; they do not
|
||||||
|
prove that future published versions or their downloaded sums already exist.
|
||||||
|
|
||||||
|
### Verified vanity discovery
|
||||||
|
|
||||||
|
The core endpoint at `https://go.maximumdirect.net/commandkit?go-get=1` serves
|
||||||
|
the mapping to `https://forge.maximumdirect.net/base-layer/commandkit`. Go direct
|
||||||
|
discovery finds the existing core v0.1.0 and v0.2.0 tags. Fetching v0.2.0 through
|
||||||
|
the vanity path reaches its source but reports a module-path mismatch because
|
||||||
|
that release declares `gitea.maximumdirect.net/eric/CommandKit`.
|
||||||
|
|
||||||
|
Both integration endpoints now return HTTP 200 with these `go-import` contents:
|
||||||
|
|
||||||
|
| Vanity path | VCS | Repository URL | Subdirectory field |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `go.maximumdirect.net/ckconfig` | `git` | `https://forge.maximumdirect.net/base-layer/commandkit` | `integrations/ckconfig` |
|
||||||
|
| `go.maximumdirect.net/ckprompt` | `git` | `https://forge.maximumdirect.net/base-layer/commandkit` | `integrations/ckprompt` |
|
||||||
|
|
||||||
|
Go 1.26.8 direct discovery finds each integration's v0.1.0 tag. Disposable
|
||||||
|
consumer fetches reach the correct nested `go.mod` and report its old declared
|
||||||
|
module path, confirming subdirectory routing. Discovery is working; the
|
||||||
|
existing tags have not migrated. The optional fourth metadata field is
|
||||||
|
supported from Go 1.25, within the existing Go 1.26.0 consumer minimum; see the
|
||||||
|
[Go discovery reference](https://go.dev/ref/mod#vcs-find).
|
||||||
|
|
||||||
|
## Targeted end state
|
||||||
|
|
||||||
|
### Compatible independently released modules
|
||||||
|
|
||||||
|
Use the following proposed next versions, subject to the existing fresh-version
|
||||||
|
publication guards:
|
||||||
|
|
||||||
|
| Module path | Physical module directory | Next release | Required CommandKit dependencies | Full tag |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `go.maximumdirect.net/commandkit` | `.` | v0.3.0 | None | `v0.3.0` |
|
||||||
|
| `go.maximumdirect.net/ckconfig` | `integrations/ckconfig` | v0.2.0 | `go.maximumdirect.net/commandkit v0.3.0` | `integrations/ckconfig/v0.2.0` |
|
||||||
|
| `go.maximumdirect.net/ckprompt` | `integrations/ckprompt` | v0.2.0 | `go.maximumdirect.net/commandkit v0.3.0` and `go.maximumdirect.net/ckconfig v0.2.0` | `integrations/ckprompt/v0.2.0` |
|
||||||
|
|
||||||
|
Minor integration releases fit the existing pre-1.0 compatibility policy: the
|
||||||
|
reporting migration changes consumer-facing behavior and adopts a core version
|
||||||
|
with incompatible API changes. The vanity migration also deliberately changes
|
||||||
|
Go module and import identities. Migration notes explain the new imports and
|
||||||
|
dependency set, provider results, invocation reporting, and application changes.
|
||||||
|
|
||||||
|
The three modules retain independent versions and source tags. Their candidates
|
||||||
|
may identify successive commits: core publication need not wait for integration
|
||||||
|
metadata that depends on that newly published core. Each selected module's
|
||||||
|
final release suite runs against its actual declared dependencies.
|
||||||
|
|
||||||
|
### Module and import identity
|
||||||
|
|
||||||
|
Change each module declaration and all CommandKit-owned imports to the matching
|
||||||
|
new path. Core self-imports, including its `internal` packages, use
|
||||||
|
`go.maximumdirect.net/commandkit/...`; integration self-imports use
|
||||||
|
`go.maximumdirect.net/ckconfig/...` or `go.maximumdirect.net/ckprompt/...`.
|
||||||
|
Cross-module imports and requirements use the complete new paths in the table,
|
||||||
|
including ckprompt's ckconfig dependency. Core component-version discovery uses
|
||||||
|
`go.maximumdirect.net/commandkit` as its build-info identity, retaining the
|
||||||
|
existing `version --scope commandkit` behavior with the new module path.
|
||||||
|
|
||||||
|
Update production files, tests, maintained examples, and any import-path-based
|
||||||
|
test expectations together. A declared vanity module that still imports the
|
||||||
|
old core path would select a different Go package identity and can violate
|
||||||
|
`internal` import rules. The matched new releases compose through one core
|
||||||
|
identity rather than mixing old-path and new-path CommandKit types.
|
||||||
|
|
||||||
|
Keep physical directories, repository URLs, and Git tag prefixes as shown. The
|
||||||
|
Go subdirectory metadata makes the short integration vanity paths resolve to
|
||||||
|
the existing layout. The existing release profiles select physical directories;
|
||||||
|
the migration does not rename those directories or flatten integration tags.
|
||||||
|
|
||||||
|
Published old-path versions remain immutable. This scope selects the new paths
|
||||||
|
for new releases and updates consumer guidance; it makes no new promise of
|
||||||
|
type interoperability between the two identities and adds no old-path forwarding
|
||||||
|
module or compatibility layer.
|
||||||
|
|
||||||
|
### Retained architecture and scope
|
||||||
|
|
||||||
|
Preserve exported declaration shapes and runtime behavior while deliberately
|
||||||
|
changing module/import identities. Retain the integration adapters, Go 1.26.0
|
||||||
|
consumer minimum, Go 1.26.8 maintainer toolchain, and root-selected ReleaseKit
|
||||||
|
v0.9.0. The earlier successful diagnostic suites establish the dependency fix
|
||||||
|
before renaming; the migrated source still needs its own validation. This work
|
||||||
|
selects no API rollback or shim for old reporting APIs.
|
||||||
|
|
||||||
|
Retain ConfigKit v0.8.0, PromptKit v0.16.0, and other dependency selections
|
||||||
|
unless ordinary tidy changes the resolved graph as a consequence of the
|
||||||
|
CommandKit updates. Inspect and explain the resulting graph rather than
|
||||||
|
bundling unrelated upgrades. In particular, transitive tools with different
|
||||||
|
module paths may coexist; attribution must reflect the actual selection.
|
||||||
|
|
||||||
|
Preserve the existing launchers, three release profiles, generic validators,
|
||||||
|
credential handling, and separate source-publication and hosted-delivery
|
||||||
|
outcomes. Development workspaces remain useful and ignored. Diagnostic local
|
||||||
|
replacements and the earlier temporary Go shim remain development evidence;
|
||||||
|
release validation uses normal downloaded dependencies without that redirection.
|
||||||
|
|
||||||
|
## Release-preparation deliverables
|
||||||
|
|
||||||
|
The core v0.3.0 draft describes the complete candidate, including completion,
|
||||||
|
reporting, aliases, tool maintenance, and the vanity-path migration. New
|
||||||
|
integration notes at `integrations/ckconfig/docs/releases/v0.2.0.md` and
|
||||||
|
`integrations/ckprompt/docs/releases/v0.2.0.md` explain the matched dependency set
|
||||||
|
and consumer migration. Their source and licensing links follow the current
|
||||||
|
release procedure; published notes and tags remain immutable.
|
||||||
|
|
||||||
|
Current installation examples, README module selections, consumer/developer
|
||||||
|
guidance, affected GoDoc, and maintained examples use the implemented new
|
||||||
|
identities. Import-path-aware tests retain their existing boundary assertions.
|
||||||
|
Source-hosting and license URLs remain repository links, distinct from Go
|
||||||
|
import paths. Module-local attribution describes each actual resolved graph.
|
||||||
|
|
||||||
|
The final record identifies module paths, versions, candidate revisions,
|
||||||
|
validation evidence, and independent source-publication outcomes. Current
|
||||||
|
readiness guidance reflects the aligned state; historical failures remain in
|
||||||
|
completed investigation records. The implementation plan owns the ordered
|
||||||
|
migration, dependency preparation, release, and evidence-recording steps.
|
||||||
|
|
||||||
|
## Validation and completion boundaries
|
||||||
|
|
||||||
|
Review current CommandKit-owned source and dependency selections for stale
|
||||||
|
self-imports or old direct dependency keys. Historical notes, source URLs, and
|
||||||
|
unrelated upstream module identities are outside that mechanical rename.
|
||||||
|
Confirm release identity still pairs each new module path with its existing
|
||||||
|
physical directory and tag prefix. The verified redirects are preparation
|
||||||
|
evidence; publishing the renamed modules is needed for real consumers to use
|
||||||
|
them through those paths.
|
||||||
|
|
||||||
|
Use the selected module's ordinary deterministic offline suite with
|
||||||
|
`GOWORK=off` and read-only metadata after dependency preparation. Its maintained
|
||||||
|
consumer compiles and runs with that suite. ReleaseKit's publication command
|
||||||
|
owns the final candidate suite and selected file checks; validation-only checks
|
||||||
|
remain optional diagnostics. Reuse successful evidence for unchanged candidates
|
||||||
|
instead of adding a required check-then-publish repetition.
|
||||||
|
|
||||||
|
Retrieving a newly published dependency is preparation for the next module's
|
||||||
|
release. It does not turn proxy availability or consumer installation into a
|
||||||
|
completion gate for the preceding source release. Continue to resolve failed
|
||||||
|
or ambiguous publication using the existing policy and upstream recovery flow.
|
||||||
|
Hosted Gitea delivery keeps its separate outcome; this roadmap adds no CI
|
||||||
|
polling, live provider calls, or additional publication gate.
|
||||||
|
|
||||||
|
Alignment is complete when the matched source versions are published under all
|
||||||
|
three vanity module paths, owned imports and examples use the new identities,
|
||||||
|
each integration declares the compatible vanity-path versions with authentic
|
||||||
|
downloaded sums, each final selected-module suite passes without workspace or
|
||||||
|
replacement redirection, and current documentation and attribution agree with
|
||||||
|
that graph. Temporary local-source success alone does not establish that end
|
||||||
|
state.
|
||||||
198
docs/roadmap/release-simplification.md
Normal file
198
docs/roadmap/release-simplification.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Release tooling simplification
|
||||||
|
|
||||||
|
Status: the selected release-tooling design has been implemented. All four
|
||||||
|
generic file checks, registered-tool execution, page-only hosted delivery, and
|
||||||
|
protection of the supplied token path are selected.
|
||||||
|
The [completed implementation record](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/87eb84ccb462bf54e6250c8b287ac217efe90883/docs/roadmap/implementation.md)
|
||||||
|
contains stage order, tasks, validation, and completion evidence. Current release
|
||||||
|
behavior stays with its canonical owners.
|
||||||
|
|
||||||
|
## Purpose and authority
|
||||||
|
|
||||||
|
Reduce CommandKit's release infrastructure to declarative module profiles,
|
||||||
|
a small launcher, and focused tests of CommandKit-owned wiring. Delegate
|
||||||
|
reusable candidate validation and publication mechanics to ReleaseKit while
|
||||||
|
retaining CommandKit's domain-specific checks in their existing owners. Add
|
||||||
|
hosted Gitea release pages as a separate local delivery operation after source
|
||||||
|
publication.
|
||||||
|
|
||||||
|
The [agent policy](../policy/agents.md),
|
||||||
|
[release policy](../policy/release.md#candidate-validation), and
|
||||||
|
[testing policy](../policy/testing.md#upstream-integration-testing-ownership)
|
||||||
|
govern implementation. The [release procedure](../release.md) continues to own
|
||||||
|
the current workflow.
|
||||||
|
|
||||||
|
## Targeted end state
|
||||||
|
|
||||||
|
### Ownership
|
||||||
|
|
||||||
|
CommandKit retains module selectors, product identities, profile locations,
|
||||||
|
the authoritative endpoint selection, release order, release-note meaning,
|
||||||
|
compatibility decisions, and attribution accuracy. Its existing public API,
|
||||||
|
CLI, integration-adaptation, and consumer-example tests remain local and run
|
||||||
|
through the owning module's suite. No parallel release-only domain suite is
|
||||||
|
needed unless a concrete CommandKit requirement is otherwise unprotected.
|
||||||
|
|
||||||
|
ReleaseKit supplies the standard offline candidate suite, selected generic file
|
||||||
|
checks, candidate/note and publication guards, tag creation and publication,
|
||||||
|
hosted delivery, outcome reporting, and recovery. CommandKit's launcher delegates
|
||||||
|
those operations. Existing source-release completion and immutability rules
|
||||||
|
continue to apply. Hosted pages are the added delivery channel; binary
|
||||||
|
deliverables and platform support commitments remain unchanged.
|
||||||
|
|
||||||
|
### Profiles
|
||||||
|
|
||||||
|
Select these existing ReleaseKit options in each profile:
|
||||||
|
|
||||||
|
| Selection | Intended protection |
|
||||||
|
| --- | --- |
|
||||||
|
| `validation.go_format: true` | Detect unformatted committed Go source. |
|
||||||
|
| `validation.markdown: {}` | Detect broken local Markdown destinations and supported heading fragments. |
|
||||||
|
| `validation.module_distribution.workspace_files: true` | Detect committed development workspace files in selected inputs. |
|
||||||
|
| `validation.module_distribution.local_replacements: true` | Detect filesystem replacements in selected committed module declarations. |
|
||||||
|
|
||||||
|
Also select
|
||||||
|
`validation.forbidden_files: [".releasekit/gitea-token"]` in all three profiles.
|
||||||
|
This delegates protection of the supplied credential path to ReleaseKit's
|
||||||
|
existing check. The path is repository-relative and the check applies across
|
||||||
|
the captured repository even for an integration release. It does not read token
|
||||||
|
contents, scan other paths, or inspect Git history.
|
||||||
|
|
||||||
|
Retain the explicit shared-file lists and use ReleaseKit's existing input scope.
|
||||||
|
The root profile does not run every nested module's suite. File checks inspect
|
||||||
|
committed inputs and do not replace formatting worktree changes or reviewing
|
||||||
|
documentation meaning, attribution, and external links. Markdown keeps its
|
||||||
|
default treatment of outside-checkout destinations; no stricter boundary mode
|
||||||
|
is proposed.
|
||||||
|
|
||||||
|
The pinned [ReleaseKit configuration reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/23203f235cc414c4ffe0fd3941030eb0c9f9943f/docs/config.md)
|
||||||
|
owns exact scope and behavior, including nested-module boundaries and vendored
|
||||||
|
inputs. Do not expand an option's scope into a general repository prohibition.
|
||||||
|
Race testing, full vet, compilation matrices, size limits, and additional
|
||||||
|
forbidden-file paths are not selected by this roadmap. They can be considered
|
||||||
|
separately for demonstrated needs.
|
||||||
|
|
||||||
|
### Launcher and recovery
|
||||||
|
|
||||||
|
Use the root module's registered `go tool releasekit` through `exec`, keeping
|
||||||
|
the current selector interface and publication argument mapping.
|
||||||
|
Retain host normalization, workspace-off execution, the local toolchain, and
|
||||||
|
read-only module metadata. Preserve other effective Go settings while arranging
|
||||||
|
read-only selection.
|
||||||
|
The implementation plan specifies the wrapper interface and invocation details.
|
||||||
|
|
||||||
|
Go's cache supplies executable storage; the launcher creates no persistent
|
||||||
|
binary directory. `/.releasekit/` remains ignored for the credential file, with
|
||||||
|
an explanatory comment. Recovery instructions use the registered tool or the
|
||||||
|
entry point printed for the saved attempt, preserving ReleaseKit's distinction
|
||||||
|
between publication outcome and record-storage errors.
|
||||||
|
|
||||||
|
Share module selection and host-safe tool invocation between publication,
|
||||||
|
delivery, and recovery where applicable. Delivery selects the same module
|
||||||
|
profile as publication; recovery uses its recorded attempt rather than inferring
|
||||||
|
identity from a current profile. Keep the existing publication interface and
|
||||||
|
provide an explicit delivery invocation without automatically chaining the two
|
||||||
|
operations. Keep one selector mapping and shared invocation plumbing.
|
||||||
|
|
||||||
|
ReleaseKit v0.9.0 shipped host-safe consumer launcher examples and regression
|
||||||
|
coverage. The earlier simplification used v0.8.0, which already supported
|
||||||
|
registered tools. Keep host normalization locally because the reviewed
|
||||||
|
`go tool` invocation inherited foreign `GOOS`/`GOARCH` values and failed to run.
|
||||||
|
Do not switch tool-version selection from the root module to each integration
|
||||||
|
merely to match the upstream multi-module example.
|
||||||
|
|
||||||
|
### Hosted Gitea release pages
|
||||||
|
|
||||||
|
Configure this delivery selection in each module profile:
|
||||||
|
|
||||||
|
| Field | Selected value |
|
||||||
|
| --- | --- |
|
||||||
|
| `delivery.forge` | `gitea` |
|
||||||
|
| `delivery.base_url` | `https://forge.maximumdirect.net` |
|
||||||
|
| `delivery.owner` | `base-layer` |
|
||||||
|
| `delivery.repository` | `commandkit` |
|
||||||
|
| `delivery.token_file` | `.releasekit/gitea-token` |
|
||||||
|
|
||||||
|
Preserve the profiles' distinct product identities and physical module
|
||||||
|
directories. All pages belong to the same Gitea repository and are keyed by
|
||||||
|
the complete tag: `vX.Y.Z` for core, `integrations/ckconfig/vX.Y.Z` for ckconfig,
|
||||||
|
and `integrations/ckprompt/vX.Y.Z` for ckprompt. Pass the selected profile and
|
||||||
|
semantic version to `deliver`; ReleaseKit derives the complete module tag.
|
||||||
|
|
||||||
|
With no build targets, delivery is page-only. ReleaseKit uses the complete
|
||||||
|
checked-in module release note, including its licensing footer, as the hosted
|
||||||
|
body. No binary assets, receipts, or checksum manifests are added. Use current
|
||||||
|
forge URLs and the full module tag for future notes' immutable source and
|
||||||
|
licensing links. Existing published tags and notes remain unchanged.
|
||||||
|
|
||||||
|
After source publication succeeds, explicitly invoke delivery from a clean
|
||||||
|
checkout whose `HEAD` identifies that published tag's commit. For later retries,
|
||||||
|
use the original tagged source and its recorded module files. Report the source
|
||||||
|
publication outcome and hosted-delivery outcome separately, including the hosted
|
||||||
|
URL on success. A delivery failure leaves the source release complete; repair
|
||||||
|
delivery without republishing or moving its tag.
|
||||||
|
|
||||||
|
Delivery performs its own read-only credential preflight, without rerunning
|
||||||
|
candidate validation or publishing a tag. Hosted-service availability and the
|
||||||
|
Gitea token do not become source-publication gates. A nonblank
|
||||||
|
`RELEASEKIT_GITEA_TOKEN` environment value takes precedence over the configured
|
||||||
|
file. The file path resolves from the repository root for all three modules.
|
||||||
|
|
||||||
|
ReleaseKit accepts matching hosted content and reports conflicting content
|
||||||
|
without overwriting it. Resolve uncertain hosted writes before retrying the
|
||||||
|
same immutable version. The pinned
|
||||||
|
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/23203f235cc414c4ffe0fd3941030eb0c9f9943f/docs/consumers/delivery.md)
|
||||||
|
owns exact delivery behavior and recovery. Start with tags containing the new
|
||||||
|
delivery configuration; historical page backfilling remains a separate decision.
|
||||||
|
This local workflow needs no CI delivery job or additional secret distribution.
|
||||||
|
|
||||||
|
### Focused local tests
|
||||||
|
|
||||||
|
Replace the release-test scaffolding with a small controlled Go-command boundary
|
||||||
|
that invokes the actual launcher. Cover:
|
||||||
|
|
||||||
|
- Selector-to-profile/module routing for core, ckconfig, and ckprompt, including
|
||||||
|
consistency with the checked-in profiles' intended module and product.
|
||||||
|
- Invalid argument handling before tool invocation.
|
||||||
|
- Invocation from another directory and paths containing spaces.
|
||||||
|
- Host normalization and workspace/toolchain/read-only settings.
|
||||||
|
- Publication and delivery argument, output-stream, and child exit-status
|
||||||
|
forwarding, with the selected module profile reaching the delivery operation.
|
||||||
|
- Profile wiring for the shared Gitea destination, token path, and its selected
|
||||||
|
committed-path protection; assertions do not read the credential file.
|
||||||
|
|
||||||
|
Exercise module routing separately from exit statuses rather than multiplying
|
||||||
|
every combination. Assert behavior and meaningful selections, not helper
|
||||||
|
implementation details or exact usage prose.
|
||||||
|
|
||||||
|
The resulting local suite omits the disposable publication exercise, generic
|
||||||
|
tag inspection, assertion-helper self-tests, and custom build-call log/parser.
|
||||||
|
Upstream publication and delivery coverage owns those generic behaviors. No
|
||||||
|
replacement publication engine or exported test-helper API is needed. There is
|
||||||
|
no line-count target for the replacement tests.
|
||||||
|
|
||||||
|
## Selected choices and rationale
|
||||||
|
|
||||||
|
All four generic file checks are selected as release-time checks, in addition
|
||||||
|
to the agreed forbidden token path. Formatting and local-link validation automate
|
||||||
|
existing contributor review work. Workspace-file and filesystem-replacement
|
||||||
|
checks address accidental inclusion of development metadata that a successful
|
||||||
|
local test run can overlook. This adds specific release checks; it does not
|
||||||
|
make every optional ReleaseKit validator mandatory or prohibit development
|
||||||
|
workspaces and replacements outside the selected committed candidate inputs.
|
||||||
|
Selecting only formatting and Markdown would change less release behavior, but
|
||||||
|
would leave these distribution mistakes to manual review. The selected upstream
|
||||||
|
checks avoid a second local implementation.
|
||||||
|
|
||||||
|
Host-normalized registered-tool execution is selected because the root module
|
||||||
|
already registers ReleaseKit and Go caches the executable. Retaining a persistent
|
||||||
|
native executable would provide a recovery entry point independent of a later
|
||||||
|
Go-driver invocation, but would retain executable-storage and bootstrap test
|
||||||
|
maintenance. Recovery instead uses the registered tool or ReleaseKit's reported
|
||||||
|
entry point. The credential directory remains ignored under either approach.
|
||||||
|
|
||||||
|
The implementation plan records the existing integration dependency-version
|
||||||
|
mismatch separately from validation of the current source. This infrastructure
|
||||||
|
sprint does not publish releases or select new library dependency versions.
|
||||||
|
Historical hosted-page backfilling and CI delivery remain separate future
|
||||||
|
choices, not prerequisites for the selected local page-only workflow.
|
||||||
184
docs/roadmap/releasekit-v0.9.0.md
Normal file
184
docs/roadmap/releasekit-v0.9.0.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# ReleaseKit v0.9.0 adoption
|
||||||
|
|
||||||
|
Status: R1 and R2 remediation, ReleaseKit v0.9.0 adoption, and the completion
|
||||||
|
assertion repair are complete. The [completed implementation record](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/c8d659dfe5ae3e834b3aeabdfd0fe29b736dc05a/docs/roadmap/implementation.md)
|
||||||
|
records stage order, tasks, and validation evidence. This document owns the
|
||||||
|
target and selected choices; it does not authorize source or hosted publication.
|
||||||
|
|
||||||
|
## Purpose and authority
|
||||||
|
|
||||||
|
Adopt ReleaseKit v0.9.0's improved recovery behavior while preserving
|
||||||
|
CommandKit's existing release-tooling architecture. Update current dependency
|
||||||
|
and maintainer documentation together, and make the validation evidence clear
|
||||||
|
about existing local defects.
|
||||||
|
|
||||||
|
The [agent policy](../policy/agents.md),
|
||||||
|
[release policy](../policy/release.md#candidate-validation),
|
||||||
|
[testing policy](../policy/testing.md#upstream-integration-testing-ownership),
|
||||||
|
and [documentation policy](../policy/documentation.md) govern the work. Current
|
||||||
|
behavior remains owned by the [release procedure](../release.md), Go declarations,
|
||||||
|
and owning package tests until implementation updates them.
|
||||||
|
|
||||||
|
## Current baseline and review evidence
|
||||||
|
|
||||||
|
Reviewed CommandKit code baseline: `4d92ee4ee6a8403b22a4172e0f04face802b9499`.
|
||||||
|
The original upgrade review used `98e7e9933353cf43f2be4a96cb0436a0bce068d2`;
|
||||||
|
its findings and validation history are retained below.
|
||||||
|
|
||||||
|
- The root [module](../../go.mod) selects
|
||||||
|
`go.maximumdirect.net/releasekit v0.8.0` as a Go tool. Its selected version
|
||||||
|
serves core, ckconfig, and ckprompt operations.
|
||||||
|
- The [shared helper](../../scripts/releasekit) already normalizes the Go host,
|
||||||
|
disables workspace discovery, selects the local toolchain, retains read-only
|
||||||
|
metadata, and invokes the full executable package path. The
|
||||||
|
[legacy entry point](../../scripts/release) delegates publication to it.
|
||||||
|
- All three profiles already select the agreed generic file checks, forbidden
|
||||||
|
token path, and page-only Gitea destination. Source publication and hosted
|
||||||
|
delivery are separate operations. The local token directory remains ignored.
|
||||||
|
- [R1 and R2](remediation.md) are complete. Commit `7eac3fe` rejects invalid
|
||||||
|
legacy selectors before invoking Go; commit `4d92ee4` restores explicit,
|
||||||
|
optional POSIX-shell invocation in launcher tests. The remediation record owns
|
||||||
|
their acceptance evidence, including passing focused tests, shell syntax
|
||||||
|
checks, and profile assertions running when no shell is available.
|
||||||
|
- Both integrations still require older published CommandKit dependencies than
|
||||||
|
their current source needs. The completed [release-tooling implementation record](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/87eb84ccb462bf54e6250c8b287ac217efe90883/docs/roadmap/implementation.md#stage-5-verify-the-final-source-and-record-validation-boundaries)
|
||||||
|
distinguishes workspace-backed tests from independent release readiness.
|
||||||
|
|
||||||
|
The upstream v0.9.0 tag identifies commit
|
||||||
|
`2180aa868ec2caa95fc8279fd59d5f0ebac41bd4`. The tag and direct Go-module
|
||||||
|
resolution were verified. The default proxy initially returned v0.8.0 for
|
||||||
|
`@latest`; select v0.9.0 explicitly rather than changing repository-wide proxy
|
||||||
|
settings to work around that observation.
|
||||||
|
|
||||||
|
Upstream [release notes](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/releases/v0.9.0.md)
|
||||||
|
describe host-safe launchers and generated recovery commands, full package-path
|
||||||
|
tool selection, preservation of wildcard and version-specific replacements in
|
||||||
|
API-only recovery, and safer manual hosted-page retries. Public Go APIs,
|
||||||
|
profiles, record formats, and the Go 1.26.0 consumer minimum remain unchanged.
|
||||||
|
The v0.8.0-to-v0.9.0 comparison also left upstream module requirements, checksums,
|
||||||
|
copyright, and license texts unchanged.
|
||||||
|
|
||||||
|
A disposable CommandKit upgrade changed only the selected ReleaseKit version in
|
||||||
|
`go.mod`. With Go 1.26.8, the root suite passed on a subsequent run, both
|
||||||
|
workspace-backed integration suites passed, and the real helper reported v0.9.0
|
||||||
|
under foreign target settings. No production publication or delivery was run.
|
||||||
|
|
||||||
|
A later disposable review at `7eac3fe` confirmed that downloading and tidying
|
||||||
|
v0.9.0 changed only the ReleaseKit requirement and its checksum entries. The
|
||||||
|
offline root suite and both workspace-backed integration suites passed with
|
||||||
|
Go 1.26.8, and the helper reported v0.9.0 under Windows/ARM64 target settings.
|
||||||
|
That snapshot excluded the then-uncommitted R2 change; it does not replace
|
||||||
|
validation of the eventual adoption commit.
|
||||||
|
|
||||||
|
The initial upgrade run failed in
|
||||||
|
[`TestCompletionCommandContext`](../../completion_context_test.go). The same
|
||||||
|
alias-order failure reproduced four times in 30 focused repetitions on the
|
||||||
|
unchanged v0.8.0 baseline. A single passing rerun therefore does not establish
|
||||||
|
stable validation. The test compares an internal alias slice in a fixed order;
|
||||||
|
commit `0acbbf4` deliberately changed completion to use unsorted lookup records
|
||||||
|
before this upgrade review.
|
||||||
|
|
||||||
|
## Targeted end state
|
||||||
|
|
||||||
|
### Tool adoption and retained architecture
|
||||||
|
|
||||||
|
The root module selects ReleaseKit v0.9.0 with authentic downloaded checksums.
|
||||||
|
Its existing tool declaration and root-owned selection remain. No integration
|
||||||
|
dependency version, library import path, Go minimum, or selected Go 1.26.8
|
||||||
|
maintainer toolchain changes as part of adoption.
|
||||||
|
|
||||||
|
Keep host normalization and full-package invocation in the existing helper:
|
||||||
|
upgrading a dependency does not update or replace local shell scripts. Keep one
|
||||||
|
module/profile mapping and the established wrapper forms. Retain the selected
|
||||||
|
profiles, token-file handling, credential-directory ignore rule, and separation
|
||||||
|
of source and hosted outcomes. No new validator, binary target, CI workflow,
|
||||||
|
publication channel, credential preflight gate, or runtime API is needed.
|
||||||
|
|
||||||
|
The new generated Go recovery commands complement the helper's existing host
|
||||||
|
handling. Recovery continues to use saved publication evidence and the upstream
|
||||||
|
record formats; CommandKit does not acquire a parallel recovery implementation.
|
||||||
|
|
||||||
|
### Release-code simplification review
|
||||||
|
|
||||||
|
The v0.9.0 review identified no additional local release implementation to remove.
|
||||||
|
The earlier simplification already delegated generic validation, publication,
|
||||||
|
delivery, and recovery to ReleaseKit. This release improves launcher examples
|
||||||
|
and generated recovery commands without adding validation options that replace
|
||||||
|
the remaining CommandKit code.
|
||||||
|
|
||||||
|
Retain the shared helper's module/profile selection and execution environment.
|
||||||
|
Host normalization must happen before Go compiles and runs ReleaseKit. Retain
|
||||||
|
the legacy publication wrapper to preserve the established maintainer interface.
|
||||||
|
The upstream multi-module launcher example adds a module map and temporary
|
||||||
|
metadata preparation for a different tool-selection model; replacing our
|
||||||
|
root-owned registered-tool helper with it would add machinery.
|
||||||
|
|
||||||
|
Retain the focused [wiring tests](../../scripts/release_test.go) for profile
|
||||||
|
selections, argument forwarding, environment, streams, status, and optional-shell
|
||||||
|
handling. ReleaseKit owns validation and recovery semantics; CommandKit's tests
|
||||||
|
continue to cover its own adapter. The adoption work therefore preserves the
|
||||||
|
release-code structure while updating the dependency and documentation, alongside
|
||||||
|
the separately planned completion assertion repair.
|
||||||
|
|
||||||
|
### Current documentation and history
|
||||||
|
|
||||||
|
Current release procedure, project-specific release policy, consumer Go-tool
|
||||||
|
selection summary, and root attribution describe v0.9.0. Upstream contract and
|
||||||
|
notice links use the corresponding immutable source revision. Record that the
|
||||||
|
host-safe launcher request has shipped upstream instead of describing it as
|
||||||
|
pending in the earlier feature roadmap.
|
||||||
|
|
||||||
|
Continue linking to upstream recovery and delivery contracts from the maintainer
|
||||||
|
procedure. Use ReleaseKit's generated recovery commands and keep local guidance
|
||||||
|
focused on CommandKit's entry points and selections.
|
||||||
|
|
||||||
|
Preserve the baseline and validation history in completed implementation and
|
||||||
|
review records. Do not mechanically replace every historical v0.8.0 reference.
|
||||||
|
Integration attribution describes its own selected published dependency graph;
|
||||||
|
update it only if that actual selection changes. No license-text replacement
|
||||||
|
is needed for the unchanged upstream texts.
|
||||||
|
|
||||||
|
### Completed remediation and remaining correctness work
|
||||||
|
|
||||||
|
R1 and R2 were delivered before the dependency upgrade, with their completion
|
||||||
|
markers and acceptance evidence in [remediation.md](remediation.md). Preserve
|
||||||
|
their selector rejection, legitimate raw helper forms, and optional-shell test
|
||||||
|
boundary during adoption; no further implementation of those findings remains.
|
||||||
|
|
||||||
|
The completion assertion compares the expected alias source/target records
|
||||||
|
without depending on their iteration order, while detecting missing, unexpected,
|
||||||
|
or duplicate records. Its routing and visible-completion assertions remain.
|
||||||
|
Production alias ordering and public contracts are unchanged. This correction
|
||||||
|
was completed as part of the maintenance scope, separate from R1/R2 remediation;
|
||||||
|
the implementation plan records the test changes and focused verification.
|
||||||
|
|
||||||
|
Ordinary releases retain their single selected-module publication suite and
|
||||||
|
separate hosted-delivery outcome. One-time upgrade diagnostics are maintenance
|
||||||
|
evidence, with no new recurring release gate. Credentials and live publication
|
||||||
|
are unnecessary for this adoption.
|
||||||
|
|
||||||
|
## Completion evidence
|
||||||
|
|
||||||
|
Adoption is demonstrated by authentic module metadata, the real helper reporting
|
||||||
|
v0.9.0 for the host, aligned current documentation and attribution, and recorded
|
||||||
|
validation of the final source. The reviewed upgrade introduced no other module
|
||||||
|
version change; verify rather than assume that result during implementation.
|
||||||
|
|
||||||
|
R1/R2 acceptance evidence is complete in the remediation record. Completion of
|
||||||
|
the remaining maintenance work also includes a stable, order-independent
|
||||||
|
completion assertion and validation that the adopted helper retains the
|
||||||
|
corrected launcher behavior. Independent integration dependency alignment
|
||||||
|
remains separate release-preparation work; this roadmap selects no future
|
||||||
|
versions or sums.
|
||||||
|
|
||||||
|
## Resolved choices
|
||||||
|
|
||||||
|
R1 and R2 were corrected in separate commits before adoption, resolving the
|
||||||
|
former OD-1 scheduling question. Their acceptance evidence remains in
|
||||||
|
[remediation.md](remediation.md).
|
||||||
|
|
||||||
|
The selected maintenance scope is the completion assertion repair, root tool
|
||||||
|
upgrade, aligned current documentation and attribution, and validation of the
|
||||||
|
resulting source. The older integration dependency-version mismatch remains
|
||||||
|
separate release-preparation work. The implementation plan defines execution
|
||||||
|
and commit sequencing.
|
||||||
116
docs/roadmap/remediation.md
Normal file
116
docs/roadmap/remediation.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Release tooling review findings
|
||||||
|
|
||||||
|
Status: R1 and R2 complete; no open findings. Review baseline:
|
||||||
|
`124a9fa57fb3f815bedbafe2d4819f6aa265ee7d`, covering the implementation commits
|
||||||
|
after `cb40f1d`. The findings below preserve the review evidence; completion
|
||||||
|
markers record the subsequent corrections. No implementation was changed during
|
||||||
|
the original review.
|
||||||
|
|
||||||
|
The selected architecture is substantially present: three declarative profiles,
|
||||||
|
one host-normalized registered-tool helper, separate publication and page-only
|
||||||
|
delivery, retained credential-directory ignoring, and focused wiring tests.
|
||||||
|
Both issues identified by the review have been addressed.
|
||||||
|
|
||||||
|
## R1 — P2: Reject raw helper command names as legacy module selectors
|
||||||
|
|
||||||
|
**Completed.** The legacy entry point now checks its three module selectors
|
||||||
|
before delegating to the helper, retaining the single profile/module mapping
|
||||||
|
there. Regression cases cover all three raw-command collisions and require
|
||||||
|
usage status 2 with no Go invocation. The new regression failed before the fix;
|
||||||
|
the complete focused scripts suite and shell syntax checks passed afterward
|
||||||
|
with Go 1.26.8, including valid legacy and direct raw-command forwarding.
|
||||||
|
|
||||||
|
**Location:** [scripts/release](../../scripts/release), lines 9–10, delegating to
|
||||||
|
the first-argument dispatch in [scripts/releasekit](../../scripts/releasekit),
|
||||||
|
lines 10–15. The invalid-form cases in
|
||||||
|
[scripts/release_test.go](../../scripts/release_test.go), lines 186–200, miss
|
||||||
|
these collisions.
|
||||||
|
|
||||||
|
The legacy interface is `scripts/release MODULE VERSION`, with three module
|
||||||
|
selectors. It now delegates its first argument directly into a helper that also
|
||||||
|
accepts `version`, `publication-attempts`, and `reconcile` as raw commands. These
|
||||||
|
invalid module names consequently bypass selector rejection and invoke Go.
|
||||||
|
|
||||||
|
For example, `./scripts/release version v1.2.3` reaches the tool as
|
||||||
|
`version publish v1.2.3`. With the real pinned tool it returns upstream's
|
||||||
|
"version takes no arguments" diagnostic after tool resolution. In a disposable
|
||||||
|
copy with a controlled Go boundary returning status 73, each of the three raw
|
||||||
|
command names returned 73 and invoked Go; an ordinary unknown selector returned
|
||||||
|
2 without invoking Go. Thus invalid legacy input depends on tool availability
|
||||||
|
and behavior instead of being rejected at the wrapper boundary.
|
||||||
|
|
||||||
|
**Required correction:** Make legacy module selection unambiguous before raw
|
||||||
|
command dispatch. Keep one profile/module mapping and the supported raw helper
|
||||||
|
forms; a small selector check is preferable to another routing framework.
|
||||||
|
Preserve upstream ownership of version and flag validation.
|
||||||
|
|
||||||
|
**Verification:** Add all three raw command names as invalid legacy selectors.
|
||||||
|
Each must return usage status 2 before any Go command runs. Valid legacy
|
||||||
|
publication and the helper's direct raw forms must retain their forwarding and
|
||||||
|
exit-status behavior. This restores the requested malformed-wrapper handling;
|
||||||
|
it adds no new accepted-version restriction.
|
||||||
|
|
||||||
|
## R2 — P2: Restore the tests' optional POSIX-shell execution boundary
|
||||||
|
|
||||||
|
**Completed.** Launcher execution tests now discover `sh`, invoke it explicitly
|
||||||
|
with the script path, and skip when it is unavailable. Profile assertions remain
|
||||||
|
independent of shell availability. A regression case removes the fixture
|
||||||
|
script's executable permission to verify interpreter invocation; it failed
|
||||||
|
before the correction and passes afterward. The focused scripts suite and shell
|
||||||
|
syntax checks passed with Go 1.26.8 on the available POSIX host. Running the
|
||||||
|
compiled test binary with an empty `PATH` confirmed that every launcher
|
||||||
|
execution case skips while all three profile assertions pass. Native Windows
|
||||||
|
execution was not tested and no new platform guarantee is introduced.
|
||||||
|
|
||||||
|
**Location:** [scripts/release_test.go](../../scripts/release_test.go), especially
|
||||||
|
`fixture.run` at line 63 and the launcher test setup.
|
||||||
|
|
||||||
|
The rewritten tests execute extensionless POSIX shell scripts directly through
|
||||||
|
`exec.Command`. They neither discover a shell nor skip launcher execution when
|
||||||
|
it is unavailable. The previous suite resolved `sh`, skipped shell-dependent
|
||||||
|
tests when it was missing, and passed the script as an argument to that shell.
|
||||||
|
|
||||||
|
Direct execution works on this review's Linux host, but relies on Unix script
|
||||||
|
execution semantics. Native Windows process creation does not supply a POSIX
|
||||||
|
interpreter for these files. Go's
|
||||||
|
[os/exec documentation](https://pkg.go.dev/os/exec#hdr-Overview) explicitly notes
|
||||||
|
that it does not invoke a system shell. Consequently the default repository
|
||||||
|
suite now fails on hosts without compatible script execution rather than
|
||||||
|
preserving the previous optional-shell behavior. This portability finding is
|
||||||
|
based on the changed execution path and platform implementation; a native
|
||||||
|
Windows test run was not available during review.
|
||||||
|
|
||||||
|
**Required correction:** Discover a POSIX shell for launcher execution tests,
|
||||||
|
invoke it explicitly with the script path, and skip those tests when the shell
|
||||||
|
is unavailable. Keep profile-only JSON assertions runnable without requiring a
|
||||||
|
shell. Preserve the reduced test architecture and existing argv/status checks.
|
||||||
|
No native Windows release launcher or additional supported-platform guarantee
|
||||||
|
is requested.
|
||||||
|
|
||||||
|
**Verification:** Confirm that launcher tests use the discovered interpreter
|
||||||
|
and handle its absence explicitly, while profile assertions still run. Rerun
|
||||||
|
the focused suite on the available POSIX host. Broader platform matrices are
|
||||||
|
not a new release gate; this correction removes an incidental environment
|
||||||
|
restriction introduced by the test rewrite.
|
||||||
|
|
||||||
|
## Review evidence and limits
|
||||||
|
|
||||||
|
- Go 1.26.8 offline root tests and the temporary-workspace suites for both
|
||||||
|
integrations passed. Both production scripts passed shell syntax checks.
|
||||||
|
- The real helper reported ReleaseKit v0.8.0 under foreign target settings.
|
||||||
|
- ReleaseKit checks passed for a disposable committed source snapshot using
|
||||||
|
the actual profiles: ordinary root validation, then both integration checks
|
||||||
|
with the implementation plan's temporary workspace-backed test process.
|
||||||
|
Selected file checks continued to inspect the committed declarations.
|
||||||
|
- Local documentation destinations and applicable Markdown fragments passed
|
||||||
|
inspection. The credential file remained ignored and its contents were not
|
||||||
|
read. No real publication or hosted-delivery operation was invoked.
|
||||||
|
- Library code and dependency declarations were unchanged by the sprint. The
|
||||||
|
documented integration dependency-version mismatch remains a pre-existing
|
||||||
|
independent-release-readiness limitation; the workspace-backed results do
|
||||||
|
not establish that published dependencies are aligned.
|
||||||
|
|
||||||
|
No additional architecture gaps or unrequested runtime/public-API guarantees
|
||||||
|
were identified. The selected file checks and forbidden token path match the
|
||||||
|
finalized scope. R2 concerns the default tests' execution environment rather
|
||||||
|
than a new production validator or library contract.
|
||||||
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](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/230187296fae5253cb1773f16c2f1d1a39499ed0/docs/roadmap/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](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/230187296fae5253cb1773f16c2f1d1a39499ed0/docs/roadmap/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](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/230187296fae5253cb1773f16c2f1d1a39499ed0/docs/roadmap/implementation.md) owns their delivery sequence.
|
||||||
39
examples/core/README.md
Normal file
39
examples/core/README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 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
|
||||||
|
go build -o core-example ./examples/core
|
||||||
|
./core-example completion bash
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The example also installs `Completion("core-example")` and gives the `report`
|
||||||
|
scenario argument static choices. Initialize `bash-completion` first if using
|
||||||
|
Bash. Build it with the basename shown above, load
|
||||||
|
the script for your shell using the [CLI reference](../../docs/cli.md#shell-completion),
|
||||||
|
and try completing `core-example report d` or `core-example ws gr`.
|
||||||
|
|
||||||
|
Compatible integration factories also return `commandkit.Command` and can be
|
||||||
|
passed directly to `New` beside `commandkit.Alias` or flag-alias declarations.
|
||||||
|
The current integrations still use the old core identity; the planned matched
|
||||||
|
vanity-path releases will use the new core identity. 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 require core v0.3.0. Repository contributors can run its test with
|
||||||
|
`go test ./examples/core` from the root.
|
||||||
@@ -2,11 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type greetInput struct {
|
type greetInput struct {
|
||||||
@@ -14,6 +16,16 @@ type greetInput struct {
|
|||||||
Tags commandkit.Supplied[[]string]
|
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) {
|
func newApp() (*commandkit.App, error) {
|
||||||
countDefault := 1
|
countDefault := 1
|
||||||
tagDefaults := []string{"demo"}
|
tagDefaults := []string{"demo"}
|
||||||
@@ -29,6 +41,7 @@ func newApp() (*commandkit.App, error) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
||||||
|
reporter := invocation.Reporter()
|
||||||
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
|
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
|
||||||
if input.Count.Set {
|
if input.Count.Set {
|
||||||
count = fmt.Sprintf("explicit %d", input.Count.Value)
|
count = fmt.Sprintf("explicit %d", input.Count.Value)
|
||||||
@@ -37,14 +50,96 @@ func newApp() (*commandkit.App, error) {
|
|||||||
if input.Tags.Set {
|
if input.Tags.Set {
|
||||||
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
|
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 {
|
_ = reporter.RenderOutput(func(writer io.Writer) error {
|
||||||
return commandkit.Failure(err)
|
_, 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()
|
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.", Completion: commandkit.CompleteChoices(
|
||||||
|
commandkit.CompletionChoice{Value: "demo"}, commandkit.CompletionChoice{Value: "recover"}, commandkit.CompletionChoice{Value: "warn"},
|
||||||
|
commandkit.CompletionChoice{Value: "warn-error"}, commandkit.CompletionChoice{Value: "fail"}, commandkit.CompletionChoice{Value: "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)
|
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.Completion("core-example"),
|
||||||
|
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() {
|
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 "go.maximumdirect.net/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()
|
||||||
|
}
|
||||||
367
execute.go
367
execute.go
@@ -5,52 +5,88 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Execute parses and dispatches one invocation. args excludes the executable
|
// Execute parses and dispatches one invocation. args excludes the executable
|
||||||
// name. Supplied values are converted before command input construction; help
|
// name. Supplied values are converted before command input construction; help
|
||||||
// does not construct caller inputs or run acquisition callbacks. CommandKit
|
// does not construct caller inputs or run acquisition callbacks. It applies the
|
||||||
// writes diagnostics to streams.Err and leaves process exit to the caller. A
|
// parsed reporting selection, finalizes invocation presentation, and returns
|
||||||
// nil context returns an operational failure, and nil streams are replaced with
|
// the resulting Outcome without exiting the process. Supplied streams remain
|
||||||
// io.Discard.
|
// 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 {
|
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)
|
current := newExecution(a, ctx, streams)
|
||||||
routedSnapshot := current.snapshot
|
if a == nil {
|
||||||
parsed := current.parse(args)
|
err := errors.New("cannot execute commands on a nil app")
|
||||||
if parsed.Failure != nil {
|
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
|
||||||
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path)
|
|
||||||
}
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
err := errors.New("invocation context must not be nil")
|
||||||
|
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
|
||||||
|
}
|
||||||
|
if current.snapshot != nil && current.snapshot.completion && len(args) > 0 && args[0] == internalcobra.CompletionQueryToken {
|
||||||
|
return current.complete(args[1:])
|
||||||
|
}
|
||||||
|
routedSnapshot := current.snapshot
|
||||||
|
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 {
|
||||||
|
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]
|
payload, ok := routedSnapshot.runners[parsed.Key]
|
||||||
if !ok || payload == nil {
|
if !ok || payload == nil {
|
||||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
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)
|
||||||
}
|
}
|
||||||
if parsed.HelpRequested || !payload.hasRun() {
|
} else {
|
||||||
if _, err := payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
|
run, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, true)
|
||||||
return current.present(Usage(err), parsed.Path)
|
|
||||||
}
|
|
||||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
|
||||||
}
|
|
||||||
run, err := payload.prepare(parsed.FlagValues, parsed.Operands, true)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return current.present(Usage(err), parsed.Path)
|
outcome = classifyInputError(err)
|
||||||
|
} else {
|
||||||
|
handlerRan = true
|
||||||
|
outcome = run(current)
|
||||||
}
|
}
|
||||||
return current.present(run(current), parsed.Path)
|
}
|
||||||
|
}
|
||||||
|
return current.finalize(outcome, path, handlerRan)
|
||||||
}
|
}
|
||||||
|
|
||||||
type execution struct {
|
type execution struct {
|
||||||
app *App
|
app *App
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
streams Streams
|
streams Streams
|
||||||
|
reporter *Reporter
|
||||||
|
debugEvents []string
|
||||||
options commandmeta.ParseOptions
|
options commandmeta.ParseOptions
|
||||||
snapshot *routingSnapshot
|
snapshot *routingSnapshot
|
||||||
session *internalcobra.Session
|
session *internalcobra.Session
|
||||||
|
route routeResult
|
||||||
}
|
}
|
||||||
|
|
||||||
type commandExecution struct {
|
type commandExecution struct {
|
||||||
@@ -60,25 +96,35 @@ type commandExecution struct {
|
|||||||
|
|
||||||
func (current *execution) commandContext() commandExecution {
|
func (current *execution) commandContext() commandExecution {
|
||||||
return commandExecution{
|
return commandExecution{
|
||||||
invocation: Invocation{Context: current.ctx, Streams: current.streams},
|
invocation: current.invocation(),
|
||||||
renderHelpTargetsFunc: current.renderHelpTargets,
|
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 {
|
func (current commandExecution) renderHelpTargets(targets []string) Outcome {
|
||||||
return current.renderHelpTargetsFunc(targets)
|
return current.renderHelpTargetsFunc(targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newExecution(app *App, ctx context.Context, streams Streams) *execution {
|
func newExecution(app *App, ctx context.Context, streams Streams) *execution {
|
||||||
|
streams = normalizeStreams(streams)
|
||||||
current := &execution{
|
current := &execution{
|
||||||
app: app,
|
app: app,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
streams: streams,
|
streams: streams,
|
||||||
|
reporter: newReporter(streams),
|
||||||
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
|
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
|
||||||
}
|
}
|
||||||
if app != nil {
|
if app != nil {
|
||||||
current.snapshot = app.routeSnapshot()
|
current.snapshot = app.routeSnapshot()
|
||||||
current.session = internalcobra.Prepare(current.snapshot.metadata, current.options)
|
|
||||||
}
|
}
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
@@ -87,7 +133,34 @@ func (current *execution) parse(args []string) commandmeta.Result {
|
|||||||
if current.app == nil {
|
if current.app == nil {
|
||||||
return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
|
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 {
|
func (current *execution) presentationSession() *internalcobra.Session {
|
||||||
@@ -97,23 +170,50 @@ func (current *execution) presentationSession() *internalcobra.Session {
|
|||||||
snapshot := current.app.routeSnapshot()
|
snapshot := current.app.routeSnapshot()
|
||||||
if current.session == nil || current.snapshot != snapshot {
|
if current.session == nil || current.snapshot != snapshot {
|
||||||
current.snapshot = snapshot
|
current.snapshot = snapshot
|
||||||
current.session = internalcobra.Prepare(snapshot.metadata, current.options)
|
current.session = nil
|
||||||
|
current.prepareSession()
|
||||||
}
|
}
|
||||||
return current.session
|
return current.session
|
||||||
}
|
}
|
||||||
|
|
||||||
func (current *execution) renderHelp(path []string) Outcome {
|
func (current *execution) renderHelp(path []string) Outcome {
|
||||||
if current.app == nil {
|
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 {
|
func (current *execution) renderHelpTargets(targets []string) Outcome {
|
||||||
if current.app == nil {
|
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 {
|
func (current *execution) usageLine(path []string) string {
|
||||||
@@ -129,71 +229,133 @@ func (current *execution) usageLine(path []string) string {
|
|||||||
return current.app.usageFallback()
|
return current.app.usageFallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (current *execution) present(outcome Outcome, path []string) Outcome {
|
func (current *execution) routeUsageLine() string {
|
||||||
return current.app.presentWithUsageLine(outcome, path, current.streams, current.usageLine)
|
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 {
|
func (current *execution) recordRouteEvents() {
|
||||||
if streams.Out == nil {
|
entered := current.route.enteredPath.String()
|
||||||
streams.Out = io.Discard
|
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))
|
||||||
|
}
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
if streams.Err == nil {
|
|
||||||
streams.Err = io.Discard
|
|
||||||
}
|
}
|
||||||
return streams
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) present(outcome Outcome, path []string, streams Streams) Outcome {
|
func (current *execution) recordSelectionEvents(result commandmeta.Result) {
|
||||||
return a.presentWithUsageLine(outcome, path, streams, a.usageLine)
|
mode := "ordinary"
|
||||||
}
|
flagName := ""
|
||||||
|
switch result.Reporting.Mode {
|
||||||
func (a *App) presentWithUsageLine(outcome Outcome, path []string, streams Streams, usageLine func([]string) string) Outcome {
|
case commandmeta.ReportingModeQuiet:
|
||||||
if outcome.Kind == "" && outcome.Status == 0 && outcome.Domain == "" &&
|
mode, flagName = "quiet", "quiet"
|
||||||
outcome.Cause == nil && outcome.DeliveryError == nil &&
|
case commandmeta.ReportingModeVerbose:
|
||||||
!outcome.ReportIncomplete && len(outcome.Diagnostics) == 0 {
|
mode, flagName = "verbose", "verbose"
|
||||||
outcome.Kind = OutcomeSuccess
|
case commandmeta.ReportingModeDebug:
|
||||||
|
mode, flagName = "debug", "debug"
|
||||||
}
|
}
|
||||||
if outcome.DiagnosticDelivery == "" {
|
event := "selected ordinary reporting mode"
|
||||||
outcome.DiagnosticDelivery = DeliveryNone
|
if flagName != "" {
|
||||||
}
|
event = fmt.Sprintf("selected %s reporting mode", mode)
|
||||||
streams = normalizeStreams(streams)
|
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
|
||||||
if outcome.DiagnosticDelivery == DeliveryComplete {
|
occurrence := result.FlagOccurrences[index]
|
||||||
return outcome
|
if occurrence.Name != flagName {
|
||||||
}
|
|
||||||
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
|
continue
|
||||||
}
|
}
|
||||||
delivered = true
|
enabled, err := strconv.ParseBool(occurrence.RawValue)
|
||||||
writer := &checkedWriter{writer: streams.Err}
|
if err != nil || !enabled {
|
||||||
writeErr := diagnostic.Write(writer)
|
continue
|
||||||
if writer.failure != nil {
|
}
|
||||||
if writeErr == nil {
|
canonical := "--" + flagName
|
||||||
writeErr = writer.failure
|
if occurrence.Spelling != canonical {
|
||||||
} else if !errors.Is(writeErr, writer.failure) {
|
event += fmt.Sprintf(" using %s (alias for %s)", occurrence.Spelling, canonical)
|
||||||
writeErr = errors.Join(writeErr, writer.failure)
|
} else {
|
||||||
|
event += fmt.Sprintf(" using %s", canonical)
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if writeErr != nil {
|
current.debugEvents = append(current.debugEvents, event)
|
||||||
return deliveryFailure(outcome, writeErr)
|
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 delivered {
|
current.debugEvents = append(current.debugEvents, event)
|
||||||
outcome.DiagnosticDelivery = DeliveryComplete
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
func (a *App) usageLine(path []string) string {
|
||||||
@@ -212,42 +374,3 @@ func (a *App) usageFallback() string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("Usage: %s [command]", name)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||||
@@ -19,7 +19,7 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var inputs []input
|
var inputs []input
|
||||||
var contexts []context.Context
|
var contexts []context.Context
|
||||||
var streams []commandkit.Streams
|
var reporters []*commandkit.Reporter
|
||||||
firstContext := context.WithValue(context.Background(), struct{}{}, "first")
|
firstContext := context.WithValue(context.Background(), struct{}{}, "first")
|
||||||
secondContext := context.WithValue(context.Background(), struct{}{}, "second")
|
secondContext := context.WithValue(context.Background(), struct{}{}, "second")
|
||||||
var firstOut, secondOut, firstDiagnostic, secondDiagnostic bytes.Buffer
|
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 {
|
Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome {
|
||||||
inputs = append(inputs, value)
|
inputs = append(inputs, value)
|
||||||
contexts = append(contexts, invocation.Context)
|
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()
|
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 {
|
if inputs[1].name.Set || inputs[1].name.Value != "" || inputs[1].ids.Set {
|
||||||
t.Fatalf("second invocation inherited state: %#v", inputs[1])
|
t.Fatalf("second invocation inherited state: %#v", inputs[1])
|
||||||
}
|
}
|
||||||
if streams[2].Out != io.Discard || streams[2].Err != io.Discard {
|
if reporters[0] == nil || reporters[1] == nil || reporters[2] == nil || reporters[0] == reporters[1] || reporters[0] == reporters[2] || reporters[1] == reporters[2] {
|
||||||
t.Fatalf("nil streams were not replaced with io.Discard: %#v", streams[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 {
|
if firstOut.String() != "first\n" || secondOut.String() != "\n" || contexts[0] != firstContext || contexts[1] != secondContext {
|
||||||
t.Fatalf("handler invocation contexts or streams were not forwarded independently")
|
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",
|
Name: "run",
|
||||||
Run: func(_ commandkit.Invocation, value *int) commandkit.Outcome {
|
Run: func(_ commandkit.Invocation, value *int) commandkit.Outcome {
|
||||||
if value != nil {
|
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()
|
return commandkit.Success()
|
||||||
},
|
},
|
||||||
@@ -136,9 +141,9 @@ func TestExecutePresentsHandlerUsageAfterRegisteringCommand(t *testing.T) {
|
|||||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
calls++
|
calls++
|
||||||
if err := app.Register(late); err != nil {
|
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)
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
@@ -195,12 +200,11 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
|
|||||||
|
|
||||||
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||||
domainCause := errors.New("domain rejected the request")
|
domainCause := errors.New("domain rejected the request")
|
||||||
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "review needed")
|
|
||||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
Name: "run",
|
Name: "run",
|
||||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
|
||||||
outcome := commandkit.Domain("sample", 2, domainCause)
|
_ = invocation.Reporter().Warn("review needed")
|
||||||
outcome.Diagnostics = []commandkit.Diagnostic{warning}
|
outcome := commandkit.Domain("sample", 2).WithExplanation("The request was rejected.").WithCause(domainCause)
|
||||||
return outcome
|
return outcome
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -209,19 +213,18 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var diagnostic bytes.Buffer
|
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) {
|
if outcome.Kind != commandkit.OutcomeDomain || outcome.Domain != "sample" || outcome.Status != 2 || !errors.Is(outcome.Cause, domainCause) {
|
||||||
t.Fatalf("domain outcome changed: %#v", outcome)
|
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)
|
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
|
||||||
}
|
}
|
||||||
warningOnly := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
warningOnly := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
Name: "warn",
|
Name: "warn",
|
||||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
|
||||||
result := commandkit.Success()
|
_ = invocation.Reporter().Warn("review needed")
|
||||||
result.Diagnostics = []commandkit.Diagnostic{warning}
|
return commandkit.Success()
|
||||||
return result
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
|
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
|
||||||
@@ -229,23 +232,18 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
diagnostic.Reset()
|
diagnostic.Reset()
|
||||||
outcome = app.Execute(context.Background(), []string{"warn"}, commandkit.Streams{Err: &diagnostic})
|
outcome = app.Execute(context.Background(), []string{"warn", "--verbose"}, commandkit.Streams{Err: &diagnostic})
|
||||||
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "review needed\n" {
|
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "WARN: review needed\n" {
|
||||||
t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String())
|
t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T) {
|
func TestExecuteDoesNotPresentOperationCauseWithoutExplanation(t *testing.T) {
|
||||||
cause := errors.New("already shown")
|
cause := errors.New("private operation detail")
|
||||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
Name: "run",
|
Name: "run",
|
||||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
outcome := commandkit.Domain("sample", 2, cause)
|
return commandkit.Domain("sample", 2).WithCause(cause)
|
||||||
outcome.Diagnostics = []commandkit.Diagnostic{{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error {
|
|
||||||
_, err := io.WriteString(writer, "presented\n")
|
|
||||||
return err
|
|
||||||
}}}
|
|
||||||
return outcome
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
@@ -254,21 +252,11 @@ func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T)
|
|||||||
}
|
}
|
||||||
var diagnostic bytes.Buffer
|
var diagnostic bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||||
if outcome.Kind != commandkit.OutcomeDomain || diagnostic.String() != "presented\n" {
|
if outcome.Kind != commandkit.OutcomeDomain || !errors.Is(outcome.Cause, cause) {
|
||||||
t.Fatalf("presented cause was duplicated: %#v, %q", outcome, diagnostic.String())
|
t.Fatalf("domain outcome changed: %#v", outcome)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(diagnostic.String(), cause.Error()) || !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
|
||||||
diagnostic.Reset()
|
t.Fatalf("cause was presented instead of the fallback explanation: %#v, %q", outcome, diagnostic.String())
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,9 +266,7 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
|||||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
Name: "run",
|
Name: "run",
|
||||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
outcome := commandkit.Failure(operationFailure)
|
return commandkit.Failure("The operation was canceled.").WithCause(operationFailure)
|
||||||
outcome.ReportIncomplete = true
|
|
||||||
return outcome
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
@@ -288,12 +274,12 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{err: writeFailure}})
|
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)
|
t.Fatalf("combined operation and delivery failure = %#v", outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
short := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: shortWriter{}})
|
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)
|
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 {
|
Run: func(_ commandkit.Invocation, value input) commandkit.Outcome {
|
||||||
received = append(received, value)
|
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)
|
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)
|
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})
|
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)
|
t.Fatalf("failed help delivery = %#v", failed)
|
||||||
}
|
}
|
||||||
out.Reset()
|
out.Reset()
|
||||||
@@ -503,7 +489,7 @@ func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) {
|
|||||||
t.Fatalf("invalid version scope = %#v, output %q", invalid, out.String())
|
t.Fatalf("invalid version scope = %#v, output %q", invalid, out.String())
|
||||||
}
|
}
|
||||||
failed := app.Execute(context.Background(), []string{"version"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic})
|
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)
|
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"}} {
|
for _, args := range [][]string{nil, {"notes"}, {"--help"}, {"-h"}, {"notes", "--help"}, {"help"}, {"help", "notes"}} {
|
||||||
var diagnostics bytes.Buffer
|
var diagnostics bytes.Buffer
|
||||||
result := app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: &diagnostics})
|
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())
|
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}})
|
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)
|
t.Fatalf("%v: result=%#v", args, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -560,46 +546,34 @@ func TestExecuteNormalizesOutcomeDefaultsAndPreservesDomainValues(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
|
var diagnostic bytes.Buffer
|
||||||
wantDelivery := commandkit.DeliveryNone
|
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||||
if tc.cause != nil {
|
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause {
|
||||||
wantDelivery = commandkit.DeliveryComplete
|
|
||||||
}
|
|
||||||
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause || result.DiagnosticDelivery != wantDelivery {
|
|
||||||
t.Fatalf("result=%#v expected=%#v", result, tc)
|
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")
|
cause := errors.New("operation failed")
|
||||||
for _, base := range []commandkit.Outcome{commandkit.Success(), commandkit.Domain("needs action", 2, nil), commandkit.Failure(cause)} {
|
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||||
base.Diagnostics = []commandkit.Diagnostic{{}, {Severity: commandkit.SeverityWarning}, {Severity: commandkit.SeverityError}}
|
Name: "run",
|
||||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return base }})
|
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||||
|
return commandkit.Failure("The operation failed.").WithCause(cause)
|
||||||
|
},
|
||||||
|
})
|
||||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var diagnostics bytes.Buffer
|
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
|
||||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostics})
|
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, cause) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
|
||||||
want := commandkit.DeliveryNone
|
t.Fatalf("nil diagnostic destination changed the outcome: %#v", result)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
296
flag_aliases.go
Normal file
296
flag_aliases.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
"go.maximumdirect.net/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, alias.neutralRecord())
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupRecords skips sorting: resolved source spellings are unique and the
|
||||||
|
// parser's lookup does not depend on record order.
|
||||||
|
func (aliases resolvedFlagAliases) lookupRecords() []commandmeta.FlagAlias {
|
||||||
|
records := make([]commandmeta.FlagAlias, 0, len(aliases.bySpelling))
|
||||||
|
for _, alias := range aliases.bySpelling {
|
||||||
|
records = append(records, alias.neutralRecord())
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
func (alias resolvedFlagAlias) neutralRecord() commandmeta.FlagAlias {
|
||||||
|
return commandmeta.FlagAlias{Source: alias.source.token(), Target: alias.target.token()}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
"go.maximumdirect.net/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 {
|
func classifyInputError(err error) Outcome {
|
||||||
if errors.Is(err, ErrFlagWiring) {
|
if errors.Is(err, ErrFlagWiring) {
|
||||||
return Failure(err)
|
return Failure(err.Error()).WithCause(err)
|
||||||
}
|
}
|
||||||
return Usage(err)
|
return Usage(err.Error()).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|||||||
6
go.mod
6
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module gitea.maximumdirect.net/eric/CommandKit
|
module go.maximumdirect.net/commandkit
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
@@ -9,10 +9,10 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||||
|
go.maximumdirect.net/releasekit v0.9.0 // indirect
|
||||||
golang.org/x/mod v0.40.0 // indirect
|
golang.org/x/mod v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
tool go.maximumdirect.net/releasekit/cmd/releasekit
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,5 +1,3 @@
|
|||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 h1:P2eDQ29BgyYqHnaz5MV+vzOk7r3s8py2y9k8JISFtJ0=
|
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
@@ -10,6 +8,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
|||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
|
go.maximumdirect.net/releasekit v0.9.0 h1:sK+Aoj6gS6ATU/1reocjwBekp4YETYENwkcX5ti6mrE=
|
||||||
|
go.maximumdirect.net/releasekit v0.9.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||||
|
|||||||
63
help_view.go
Normal file
63
help_view.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||||
|
"go.maximumdirect.net/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
|
Revision string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streams contains the output destinations associated with a command
|
// Streams contains the caller-owned output destinations associated with an
|
||||||
// invocation.
|
// 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 {
|
type Streams struct {
|
||||||
// Out receives help and command reports.
|
// Out receives help and command reports.
|
||||||
Out io.Writer
|
Out io.Writer
|
||||||
@@ -27,11 +30,41 @@ type Streams struct {
|
|||||||
Err io.Writer
|
Err io.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invocation provides execution context and output streams to a command
|
func normalizeStreams(streams Streams) Streams {
|
||||||
// handler.
|
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 {
|
type Invocation struct {
|
||||||
// Context carries caller cancellation and request-scoped values.
|
// Context carries caller cancellation and request-scoped values.
|
||||||
Context context.Context
|
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 {
|
if !errors.As(gotErr, &typed) || typed != loadErr {
|
||||||
t.Fatalf("load error type not preserved: %#v", typed)
|
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 {
|
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || len(acquired.Explanation.Details) != 1 {
|
||||||
t.Fatalf("retained diagnostics = %#v", acquired.Diagnostics)
|
t.Fatalf("retained diagnostics/explanation = %#v / %#v", acquired.Diagnostics, acquired.Explanation)
|
||||||
}
|
}
|
||||||
var leftText, rightText bytes.Buffer
|
var leftText, rightText bytes.Buffer
|
||||||
if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
|
if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := acquired.Diagnostics[1].Write(&rightText); err != nil {
|
if err := acquired.Explanation.Details[0](&rightText); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) {
|
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)
|
t.Fatalf("left failure/right load = %v / %v", err, rightCalled)
|
||||||
}
|
}
|
||||||
var typed *configkit.LoadError
|
var typed *configkit.LoadError
|
||||||
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Explanation.Details) != 1 {
|
||||||
t.Fatalf("left failure identity/diagnostics = %v / %#v", err, acquired.Diagnostics)
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
integrations/ckconfig/completion.go
Normal file
11
integrations/ckconfig/completion.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
|
|
||||||
|
func completionChoices(values ...string) commandkit.CompletionHint {
|
||||||
|
choices := make([]commandkit.CompletionChoice, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
choices[i].Value = value
|
||||||
|
}
|
||||||
|
return commandkit.CompleteChoices(choices...)
|
||||||
|
}
|
||||||
66
integrations/ckconfig/completion_test.go
Normal file
66
integrations/ckconfig/completion_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionUsesInstalledScopeCapabilitiesWithoutAcquisition(t *testing.T) {
|
||||||
|
var inputs, states, acquisitions, comparisons int
|
||||||
|
suite := Suite[struct{}, struct{}]{
|
||||||
|
NewInput: func() struct{} { inputs++; return struct{}{} },
|
||||||
|
NewState: func() struct{} { states++; return struct{}{} },
|
||||||
|
Scopes: []Scope[struct{}, struct{}]{
|
||||||
|
{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||||
|
comparisons++
|
||||||
|
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||||
|
}},
|
||||||
|
{Name: "other", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
acquisitions++
|
||||||
|
return commandkit.Acquired[View]{}, nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(suite), commandkit.Completion("example"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
contains, omit string
|
||||||
|
}{
|
||||||
|
{[]string{"__complete", "config", "diff", "--scope="}, "app\n", "other\n"},
|
||||||
|
{[]string{"__complete", "config", "validate", "--scope="}, "other\n", "all\n"},
|
||||||
|
{[]string{"__complete", "config", "diff", "--format="}, "yaml\n", "text\n"},
|
||||||
|
{[]string{"__complete", "config", "validate", "--format="}, "text\n", "invalid\n"},
|
||||||
|
{[]string{"__complete", "config", "diff", "--against-config=fixtures/"}, ":0\n", ":4\n"},
|
||||||
|
} {
|
||||||
|
var output bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), tc.args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
if outcome.Status != 0 || !strings.Contains(output.String(), tc.contains) || strings.Contains(output.String(), tc.omit) {
|
||||||
|
t.Fatalf("query %q outcome=%#v output=%q", tc.args, outcome, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allSuite := Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{
|
||||||
|
{Name: "app", Operations: []Operation{Validate}, Acquire: suite.Scopes[1].Acquire},
|
||||||
|
{Name: "other", Operations: []Operation{Validate}, Acquire: suite.Scopes[1].Acquire},
|
||||||
|
}}
|
||||||
|
allApp, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(allSuite), commandkit.Completion("example"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var allOutput bytes.Buffer
|
||||||
|
allOutcome := allApp.Execute(context.Background(), []string{"__complete", "config", "validate", "--scope="}, commandkit.Streams{Out: &allOutput})
|
||||||
|
if allOutcome.Status != 0 || !strings.Contains(allOutput.String(), "all\n") {
|
||||||
|
t.Fatalf("all-scope query outcome=%#v output=%q", allOutcome, allOutput.String())
|
||||||
|
}
|
||||||
|
if inputs != 0 || states != 0 || acquisitions != 0 || comparisons != 0 {
|
||||||
|
t.Fatalf("completion ran suite work: %d %d %d %d", inputs, states, acquisitions, comparisons)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) {
|
||||||
result, err := load(ctx, request.Selection, input, state)
|
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 {
|
if err != nil {
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if errors.As(err, &loadError) && loadError != nil {
|
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 {
|
if result == nil {
|
||||||
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
||||||
}
|
}
|
||||||
|
return acquired, nil
|
||||||
sources := result.Sources()
|
|
||||||
return commandkit.Acquired[View]{
|
|
||||||
Value: View{Effective: result, Sources: result, Explanation: result},
|
|
||||||
Diagnostics: configKitDiagnostics(name, sources, result.Diagnostics()),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
|
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err)
|
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)
|
acquired := commandkit.Acquired[[]configkit.Difference]{}
|
||||||
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
|
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 {
|
if err != nil {
|
||||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
|
return acquired, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctx.Err(); err != nil {
|
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)
|
right, rightDiagnostics, rightExplanation, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
|
||||||
diagnostics = append(diagnostics, rightDiagnostics...)
|
acquired.Diagnostics = append(acquired.Diagnostics, rightDiagnostics...)
|
||||||
|
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, rightExplanation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
|
return acquired, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctx.Err(); err != nil {
|
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 {
|
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
|
return scope
|
||||||
}
|
}
|
||||||
@@ -90,50 +95,74 @@ func loadComparisonSide[I, S, T any](
|
|||||||
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
|
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
|
||||||
request ComparisonSide[I],
|
request ComparisonSide[I],
|
||||||
state S,
|
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)
|
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 {
|
if err != nil {
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
var diagnostics []commandkit.Diagnostic
|
|
||||||
if errors.As(err, &loadError) && loadError != nil {
|
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 {
|
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)
|
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)
|
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 {
|
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 {
|
for _, record := range records {
|
||||||
if record.Severity == configkit.SeverityError {
|
if record.Severity == configkit.SeverityError {
|
||||||
severity = commandkit.SeverityError
|
terminal = append(terminal, record)
|
||||||
break
|
} else {
|
||||||
|
warnings = append(warnings, record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture the upstream snapshots so later calls to Write do not depend on
|
if len(warnings) == 0 {
|
||||||
// slices returned by the provider.
|
return nil, explanationWithConfigKitRecords(heading, sources, terminal)
|
||||||
sources = append([]configkit.SourceInfo(nil), sources...)
|
}
|
||||||
records = append([]configkit.Diagnostic(nil), records...)
|
warning := commandkit.Diagnostic{
|
||||||
return []commandkit.Diagnostic{{
|
Severity: commandkit.SeverityWarning,
|
||||||
Severity: severity,
|
Write: configKitDiagnosticWriter(heading, sources, warnings),
|
||||||
Write: func(writer io.Writer) error {
|
}
|
||||||
|
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 {
|
if err := writeDiagnosticText(writer, heading); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -149,8 +178,27 @@ func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 {
|
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 selects the standard handlers exposed for this scope.
|
||||||
Operations []Operation
|
Operations []Operation
|
||||||
// Acquire loads the selected configuration for one ordinary inspection
|
// 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)
|
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
|
||||||
// Compare compares two selections for this scope using one shared state.
|
// Compare compares two selections for this scope using one shared state.
|
||||||
// Its Acquired result carries native difference records and diagnostics;
|
// Its Acquired result carries native difference records, independent
|
||||||
// returned errors retain their supported cause identity. It is required when
|
// diagnostics, and terminal context; returned errors retain their supported
|
||||||
// Diff is declared in Operations.
|
// cause identity. It is required when Diff is declared in Operations.
|
||||||
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
|
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
|
||||||
|
|
||||||
issues []error
|
issues []error
|
||||||
|
|||||||
@@ -126,14 +126,20 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
||||||
type settings struct {
|
path := configkit.Keys("schema_version")
|
||||||
Name string `yaml:"name"`
|
_, loadErr := configkit.Load(configkit.Bytes("bad source", []byte("{}\n")), configkit.Options[struct{}]{
|
||||||
}
|
Definition: configkit.Definition[struct{}]{Version: &configkit.VersionRule{
|
||||||
_, loadErr := configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[settings]{Definition: configkit.Definition[settings]{}})
|
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 {
|
if loadErr == nil {
|
||||||
t.Fatal("fixture did not produce a ConfigKit load error")
|
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
|
return nil, loadErr
|
||||||
})
|
})
|
||||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||||
@@ -144,38 +150,121 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
|||||||
if !errors.As(gotErr, &typed) {
|
if !errors.As(gotErr, &typed) {
|
||||||
t.Fatalf("load error type was not retained: %T", gotErr)
|
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)
|
t.Fatalf("failure diagnostics = %#v", acquired.Diagnostics)
|
||||||
}
|
}
|
||||||
var rendered bytes.Buffer
|
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
|
||||||
if err := acquired.Diagnostics[0].Write(&rendered); err != nil {
|
t.Fatalf("failure explanation = %#v", acquired.Explanation)
|
||||||
|
}
|
||||||
|
var warning, terminal bytes.Buffer
|
||||||
|
if err := acquired.Diagnostics[0].Write(&warning); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) {
|
if err := acquired.Explanation.Details[0](&terminal); err != nil {
|
||||||
t.Fatalf("diagnostic output lacks scope/source legend: %q", rendered.String())
|
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) {
|
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 {
|
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
|
||||||
t.Fatalf("warning diagnostics = %#v", warning)
|
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.SeverityWarning, Message: "safe warning"},
|
||||||
{Severity: configkit.SeverityError, Message: "safe error"},
|
{Severity: configkit.SeverityError, Message: "safe error"},
|
||||||
})
|
})
|
||||||
if withError[0].Severity != commandkit.SeverityError {
|
if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
|
||||||
t.Fatalf("mixed diagnostics severity = %q", withError[0].Severity)
|
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)
|
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")
|
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)
|
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{}
|
type shortWriter struct{}
|
||||||
|
|||||||
@@ -9,35 +9,28 @@ import (
|
|||||||
|
|
||||||
func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||||
if plan.comparison == nil || plan.comparison.scope == nil {
|
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
|
selected := plan.comparison
|
||||||
if err := invocation.Context.Err(); err != nil {
|
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
|
compare := selected.scope.scope.Compare
|
||||||
if compare == nil {
|
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)
|
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 {
|
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 {
|
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)
|
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||||
if err != nil {
|
return renderDiffReport(acquired.Value)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,21 +101,30 @@ func TestConfigDiffRunsComparisonOnlyScopeAndRejectsUnsupportedScopeBeforeWork(t
|
|||||||
|
|
||||||
func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) {
|
func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) {
|
||||||
wantErr := errors.New("provider unavailable")
|
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")
|
_, err := io.WriteString(writer, "provider warning\n")
|
||||||
return err
|
return err
|
||||||
}}
|
}}
|
||||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||||
Name: "app", Operations: []Operation{Diff},
|
Name: "app", Operations: []Operation{Diff},
|
||||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
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
|
var output, diagnosticOutput bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
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) || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityError {
|
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || output.Len() != 0 {
|
||||||
t.Fatalf("provider failure outcome/output = %#v / %q", outcome, output.String())
|
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) {
|
func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
|
||||||
@@ -128,8 +137,8 @@ func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}}}))
|
}}}))
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
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.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 {
|
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())
|
t.Fatalf("report preparation failure outcome/output = %#v / %q", outcome, output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,8 +155,8 @@ func TestConfigDiffCancellationAfterAcquisitionRetainsDiagnosticsWithoutWriting(
|
|||||||
}}}))
|
}}}))
|
||||||
defer cancel()
|
defer cancel()
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
outcome := app.Execute(ctx, []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
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) || outcome.ReportIncomplete || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
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())
|
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) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
|
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)
|
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) {
|
func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
|
||||||
type input struct {
|
type input struct {
|
||||||
ID int
|
ID int
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// Package ckconfig provides CommandKit's optional ConfigKit integration. Its
|
// Package ckconfig provides CommandKit's optional ConfigKit integration. Its
|
||||||
// declarations install the standard config command family through the core
|
// canonical Command declaration installs the standard config command family
|
||||||
// commandkit package. Source reports preserve ConfigKit's destination,
|
// through the core commandkit package. Applications can install CommandKit
|
||||||
// placement, and version-check evidence; importing ckconfig alone installs no
|
// RoutingRule declarations alongside it through commandkit.New or
|
||||||
// commands.
|
// 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
|
package ckconfig
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Run the maintained example with:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run . config show
|
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 . config diff --config fixtures/before.yml --against-config fixtures/after.yml
|
||||||
go run . greet
|
go run . greet
|
||||||
go run . greet --count=0 --tag=first --tag second
|
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
|
With no `--config`, application settings use the in-memory default. A supplied
|
||||||
configuration and overlay are passed to ConfigKit unchanged. The comparison
|
configuration and overlay are passed to ConfigKit unchanged. The comparison
|
||||||
command loads the two local fixtures and reports `changes.greeting: Goodbye`.
|
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
|
greeting: Goodbye
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
schema:
|
||||||
|
version: 1
|
||||||
greeting: Hello
|
greeting: Hello
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -12,6 +13,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type appConfiguration struct {
|
type appConfiguration struct {
|
||||||
|
Schema struct {
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
} `yaml:"schema"`
|
||||||
Greeting string `yaml:"greeting"`
|
Greeting string `yaml:"greeting"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,12 +26,25 @@ type greetInput struct {
|
|||||||
|
|
||||||
func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*configkit.Result[appConfiguration], error) {
|
func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*configkit.Result[appConfiguration], error) {
|
||||||
source := configkit.Mapping("simple application settings", map[string]any{
|
source := configkit.Mapping("simple application settings", map[string]any{
|
||||||
|
"schema": map[string]any{"version": 2},
|
||||||
"greeting": "Hello",
|
"greeting": "Hello",
|
||||||
})
|
})
|
||||||
if selection.Config.Set {
|
if selection.Config.Set {
|
||||||
source = configkit.File(selection.Config.Value)
|
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 {
|
if selection.Overlay.Set {
|
||||||
options.Overlay = selection.Overlay.Value
|
options.Overlay = selection.Overlay.Value
|
||||||
options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."}
|
options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."}
|
||||||
@@ -50,9 +67,10 @@ func newApp() (*commandkit.App, error) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
||||||
|
reporter := invocation.Reporter()
|
||||||
configuration, err := loadAppConfiguration(invocation.Context, ckconfig.Selection{})
|
configuration, err := loadAppConfiguration(invocation.Context, ckconfig.Selection{})
|
||||||
if err != nil {
|
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)
|
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
|
||||||
if input.Count.Set {
|
if input.Count.Set {
|
||||||
@@ -62,9 +80,10 @@ func newApp() (*commandkit.App, error) {
|
|||||||
if input.Tags.Set {
|
if input.Tags.Set {
|
||||||
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
|
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 {
|
_ = reporter.RenderOutput(func(writer io.Writer) error {
|
||||||
return commandkit.Failure(err)
|
_, err := fmt.Fprintf(writer, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags)
|
||||||
}
|
return err
|
||||||
|
})
|
||||||
return commandkit.Success()
|
return commandkit.Success()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
@@ -42,7 +43,7 @@ func TestSimpleAppComparesFixturesAndKeepsDefaultCommands(t *testing.T) {
|
|||||||
|
|
||||||
output.Reset()
|
output.Reset()
|
||||||
outcome = app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
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())
|
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 {
|
func field(mapping *yaml.Node, name string) *yaml.Node {
|
||||||
if mapping == nil || mapping.Kind != yaml.MappingNode {
|
if mapping == nil || mapping.Kind != yaml.MappingNode {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package ckconfig
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
@@ -54,70 +53,54 @@ type secretReferenceDTO struct {
|
|||||||
func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||||
selected := plan.scopes[0]
|
selected := plan.scopes[0]
|
||||||
name := selected.scope.scope.Name
|
name := selected.scope.scope.Name
|
||||||
|
reporter := invocation.Reporter()
|
||||||
if err := invocation.Context.Err(); err != nil {
|
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 {
|
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{
|
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||||
Operation: Explain,
|
Operation: Explain,
|
||||||
Selection: plan.selection,
|
Selection: plan.selection,
|
||||||
}, selected.input, state)
|
}, selected.input, state)
|
||||||
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
|
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||||
if acquireErr != nil {
|
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 {
|
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 {
|
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)
|
explanation, found := acquired.Value.Explanation.Explain(plan.path)
|
||||||
if !found {
|
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 {
|
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" {
|
if plan.format != "yaml" {
|
||||||
return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, plan.path, diagnostics)
|
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()
|
||||||
|
}
|
||||||
|
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||||
var report bytes.Buffer
|
var report bytes.Buffer
|
||||||
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
|
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
|
||||||
return configOperationFailure(err, diagnostics, false)
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := invocation.Context.Err(); err != nil {
|
return report.Bytes(), 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if renderErr != nil {
|
|
||||||
outcome := configOperationFailure(renderErr, diagnostics, true)
|
|
||||||
outcome.DeliveryError = checked.failure
|
|
||||||
return outcome
|
|
||||||
}
|
|
||||||
outcome := commandkit.Success()
|
|
||||||
outcome.Diagnostics = diagnostics
|
|
||||||
return outcome
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {
|
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ckconfig
|
package ckconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,7 +11,6 @@ import (
|
|||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type scopeMetadata struct {
|
type scopeMetadata struct {
|
||||||
@@ -260,24 +260,23 @@ func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit.
|
|||||||
Name: "scopes",
|
Name: "scopes",
|
||||||
Summary: "List configuration scopes and supported operations.",
|
Summary: "List configuration scopes and supported operations.",
|
||||||
Flags: []commandkit.Flag[scopeInventoryInput]{commandkit.ValueFlag(
|
Flags: []commandkit.Flag[scopeInventoryInput]{commandkit.ValueFlag(
|
||||||
commandkit.FlagMeta{Name: "format", Usage: "Output format: text or yaml."}, &formatDefault, parseConfigString,
|
commandkit.FlagMeta{Name: "format", Usage: "Output format: text or yaml.", Completion: completionChoices("text", "yaml")}, &formatDefault, parseConfigString,
|
||||||
func(input *scopeInventoryInput, value commandkit.Supplied[string]) { input.Format = value.Value },
|
func(input *scopeInventoryInput, value commandkit.Supplied[string]) { input.Format = value.Value },
|
||||||
)},
|
)},
|
||||||
CheckInput: func(input scopeInventoryInput) error { return checkConfigFormat(input.Format) },
|
CheckInput: func(input scopeInventoryInput) error { return checkConfigFormat(input.Format) },
|
||||||
Run: func(invocation commandkit.Invocation, input scopeInventoryInput) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, input scopeInventoryInput) commandkit.Outcome {
|
||||||
var output []byte
|
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||||
var err error
|
var report bytes.Buffer
|
||||||
if input.Format == "yaml" {
|
if input.Format == "yaml" {
|
||||||
output, err = yaml.Marshal(prepared.inventory)
|
if err := encodeYAML(&report, prepared.inventory); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
output = []byte(formatScopeInventory(prepared.inventory))
|
_, _ = report.WriteString(formatScopeInventory(prepared.inventory))
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return commandkit.Failure(err)
|
|
||||||
}
|
|
||||||
if err := writeConfigReport(invocation.Streams.Out, output); err != nil {
|
|
||||||
return incompleteConfigReport(err)
|
|
||||||
}
|
}
|
||||||
|
_, err := writer.Write(report.Bytes())
|
||||||
|
return err
|
||||||
|
})
|
||||||
return commandkit.Success()
|
return commandkit.Success()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -314,21 +313,3 @@ func checkConfigFormat(format string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseConfigString(value string) (string, error) { return value, nil }
|
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))
|
app := newConfigTestApp(t, Command(suite))
|
||||||
var output, diagnostic bytes.Buffer
|
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") {
|
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())
|
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) {
|
func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
var acquisitionOutputLengths []int
|
var acquisitionOutputLengths []int
|
||||||
@@ -244,7 +289,7 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
|
|||||||
makeScope("promptkit", "---\nname: promptkit\n", providerErr),
|
makeScope("promptkit", "---\nname: promptkit\n", providerErr),
|
||||||
}}))
|
}}))
|
||||||
outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
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())
|
t.Fatalf("aggregate provider failure outcome=%#v output=%q", outcome, output.String())
|
||||||
}
|
}
|
||||||
if len(acquisitionOutputLengths) != 2 || acquisitionOutputLengths[0] != 0 || acquisitionOutputLengths[1] != 0 {
|
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),
|
makeScope("promptkit", "name: promptkit\n", nil),
|
||||||
}}))
|
}}))
|
||||||
outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
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)
|
t.Fatalf("aggregate show outcome = %#v", outcome)
|
||||||
}
|
}
|
||||||
want := "---\n# scope: app\nname: app\n---\n# scope: promptkit\nname: promptkit\n"
|
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) {
|
func TestSourcesReadsNativeInventoryOnceAndPreservesZeroValues(t *testing.T) {
|
||||||
reader := &sourceInventory{values: []configkit.SourceInfo{{
|
reader := &sourceInventory{values: []configkit.SourceInfo{{
|
||||||
ID: "synthetic", Label: "", Path: "", Layer: configkit.Layer("base"), Kind: configkit.SourceKind("mapping"), UnderlayIndex: 0,
|
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")
|
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) {
|
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
|
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})
|
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)
|
t.Fatalf("source delivery failure outcome = %#v", outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +512,7 @@ func TestReportWriteFailureTracksIncompleteDeliveryAndCancellation(t *testing.T)
|
|||||||
}}))
|
}}))
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
outcome = app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
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())
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type failedEffective struct{ err error }
|
||||||
|
|
||||||
|
func (value failedEffective) WriteEffective(io.Writer) error {
|
||||||
|
return value.err
|
||||||
|
}
|
||||||
|
|
||||||
type sourceInventory struct {
|
type sourceInventory struct {
|
||||||
values []configkit.SourceInfo
|
values []configkit.SourceInfo
|
||||||
calls int
|
calls int
|
||||||
@@ -377,6 +546,19 @@ type nativeExplanation struct {
|
|||||||
found bool
|
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) {
|
func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
|
||||||
reader.path = path
|
reader.path = path
|
||||||
return reader.value, reader.found
|
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 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
|
||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
|
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=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
|||||||
@@ -45,27 +45,43 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
|
|||||||
if operation == Show || operation == Diff {
|
if operation == Show || operation == Diff {
|
||||||
defaultFormat = "yaml"
|
defaultFormat = "yaml"
|
||||||
}
|
}
|
||||||
|
formats := completionChoices("text", "yaml")
|
||||||
|
if operation == Show || operation == Diff {
|
||||||
|
formats = completionChoices("yaml")
|
||||||
|
}
|
||||||
|
var scopeChoices []commandkit.CompletionChoice
|
||||||
|
allSupported := len(prepared.scopes) > 0
|
||||||
|
for _, scope := range prepared.scopes {
|
||||||
|
if containsOperation(scope.scope.Operations, operation) {
|
||||||
|
scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: scope.scope.Name})
|
||||||
|
} else {
|
||||||
|
allSupported = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allSupported && (operation == Validate || operation == Show) {
|
||||||
|
scopeChoices = append(scopeChoices, commandkit.CompletionChoice{Value: "all"})
|
||||||
|
}
|
||||||
flags := []commandkit.Flag[*configCommandInput[I, S]]{
|
flags := []commandkit.Flag[*configCommandInput[I, S]]{
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported."}, &defaultScope, parseConfigString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported.", Completion: commandkit.CompleteChoices(scopeChoices...)}, &defaultScope, parseConfigString,
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }),
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Output format."}, &defaultFormat, parseConfigString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Output format.", Completion: formats}, &defaultFormat, parseConfigString,
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).format = value }),
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).format = value }),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source."}, (*string)(nil), parseConfigString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Config = value
|
(*input).selection.Config = value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Overlay source."}, (*string)(nil), parseConfigString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Overlay source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Overlay = value
|
(*input).selection.Overlay = value
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
if operation == Diff {
|
if operation == Diff {
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side."}, (*string)(nil), parseConfigString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||||
(*input).against.Config = value
|
(*input).against.Config = value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side."}, (*string)(nil), parseConfigString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
|
||||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||||
(*input).against.Overlay = value
|
(*input).against.Overlay = value
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
package ckconfig
|
package ckconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Operation, input *configCommandInput[I, S]) commandkit.Outcome {
|
func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Operation, input *configCommandInput[I, S]) commandkit.Outcome {
|
||||||
if input == nil || input.plan == nil {
|
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 {
|
if err := invocation.Context.Err(); err != nil {
|
||||||
return commandkit.Failure(err)
|
return configFailure("Configuration operation was canceled.", commandkit.Explanation{}, err)
|
||||||
}
|
}
|
||||||
plan := input.plan
|
plan := input.plan
|
||||||
var state S
|
var state S
|
||||||
@@ -31,19 +31,70 @@ func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Op
|
|||||||
case Diff:
|
case Diff:
|
||||||
return runConfigDiff(invocation, plan, state)
|
return runConfigDiff(invocation, plan, state)
|
||||||
default:
|
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 {
|
func queueConfigDiagnostics(reporter *commandkit.Reporter, diagnostics []commandkit.Diagnostic) {
|
||||||
outcome := commandkit.Failure(cause)
|
deferred := reporter.AtEnd()
|
||||||
outcome.Diagnostics = diagnostics
|
for _, diagnostic := range diagnostics {
|
||||||
outcome.ReportIncomplete = reportStarted
|
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
|
return outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
func configDeliveryFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome {
|
func configAcquisitionFailure(
|
||||||
outcome := configOperationFailure(cause, diagnostics, reportStarted)
|
reporter *commandkit.Reporter,
|
||||||
outcome.DeliveryError = cause
|
operation, scope string,
|
||||||
return outcome
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,26 @@
|
|||||||
"product": "CommandKit ckconfig",
|
"product": "CommandKit ckconfig",
|
||||||
"module_dir": "integrations/ckconfig",
|
"module_dir": "integrations/ckconfig",
|
||||||
"remote": "origin",
|
"remote": "origin",
|
||||||
"expected_push_url": "https://gitea.maximumdirect.net/eric/CommandKit.git",
|
"expected_push_url": "ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git",
|
||||||
"validation": {
|
"validation": {
|
||||||
"shared_files": [
|
"shared_files": [
|
||||||
"COPYRIGHT",
|
"COPYRIGHT",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"docs/oss/attribution.md"
|
"docs/oss/attribution.md"
|
||||||
]
|
],
|
||||||
|
"go_format": true,
|
||||||
|
"markdown": {},
|
||||||
|
"module_distribution": {
|
||||||
|
"workspace_files": true,
|
||||||
|
"local_replacements": true
|
||||||
|
},
|
||||||
|
"forbidden_files": [".releasekit/gitea-token"]
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"forge": "gitea",
|
||||||
|
"base_url": "https://forge.maximumdirect.net",
|
||||||
|
"owner": "base-layer",
|
||||||
|
"repository": "commandkit",
|
||||||
|
"token_file": ".releasekit/gitea-token"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -18,101 +17,76 @@ type effectiveDocument struct {
|
|||||||
|
|
||||||
func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||||
documents := make([]effectiveDocument, 0, len(plan.scopes))
|
documents := make([]effectiveDocument, 0, len(plan.scopes))
|
||||||
diagnostics := make([]commandkit.Diagnostic, 0)
|
reporter := invocation.Reporter()
|
||||||
for _, selected := range plan.scopes {
|
for _, selected := range plan.scopes {
|
||||||
if err := invocation.Context.Err(); err != nil {
|
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 {
|
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{
|
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||||
Operation: Show,
|
Operation: Show,
|
||||||
Selection: plan.selection,
|
Selection: plan.selection,
|
||||||
}, selected.input, state)
|
}, selected.input, state)
|
||||||
diagnostics = append(diagnostics, acquired.Diagnostics...)
|
|
||||||
if acquireErr != nil {
|
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 {
|
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 {
|
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})
|
documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value})
|
||||||
}
|
}
|
||||||
if err := invocation.Context.Err(); err != nil {
|
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" {
|
if len(documents) == 1 && plan.scopeName != "all" {
|
||||||
checked := &checkedWriter{writer: invocation.Streams.Out}
|
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||||
renderErr := documents[0].view.Effective.WriteEffective(checked)
|
return documents[0].view.Effective.WriteEffective(writer)
|
||||||
if checked.failure != nil {
|
})
|
||||||
if renderErr == nil {
|
if err != nil && invocation.Context.Err() != nil {
|
||||||
renderErr = checked.failure
|
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
|
||||||
} else {
|
|
||||||
renderErr = errors.Join(renderErr, checked.failure)
|
|
||||||
}
|
}
|
||||||
}
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
written := false
|
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||||
for index, document := range documents {
|
for index, document := range documents {
|
||||||
if err := invocation.Context.Err(); err != nil {
|
if err := invocation.Context.Err(); err != nil {
|
||||||
return configOperationFailure(err, diagnostics, written)
|
return err
|
||||||
}
|
}
|
||||||
var native bytes.Buffer
|
var native bytes.Buffer
|
||||||
if err := document.view.Effective.WriteEffective(&native); err != nil {
|
if err := document.view.Effective.WriteEffective(&native); err != nil {
|
||||||
return configOperationFailure(err, diagnostics, written)
|
return err
|
||||||
}
|
}
|
||||||
if err := invocation.Context.Err(); err != nil {
|
if err := invocation.Context.Err(); err != nil {
|
||||||
return configOperationFailure(err, diagnostics, written)
|
return err
|
||||||
}
|
}
|
||||||
framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes())
|
framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes())
|
||||||
if index > 0 && hasDirective {
|
if index > 0 && hasDirective {
|
||||||
if err := writeConfigReport(invocation.Streams.Out, []byte("...\n")); err != nil {
|
if _, err := io.WriteString(writer, "...\n"); err != nil {
|
||||||
return configDeliveryFailure(err, diagnostics, true)
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' {
|
if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' {
|
||||||
framed = append(framed, '\n')
|
framed = append(framed, '\n')
|
||||||
}
|
}
|
||||||
if err := writeConfigReport(invocation.Streams.Out, framed); err != nil {
|
if _, err := writer.Write(framed); err != nil {
|
||||||
return configDeliveryFailure(err, diagnostics, true)
|
return err
|
||||||
}
|
}
|
||||||
written = true
|
|
||||||
}
|
}
|
||||||
outcome := commandkit.Success()
|
return nil
|
||||||
outcome.Diagnostics = diagnostics
|
})
|
||||||
return outcome
|
if err != nil && invocation.Context.Err() != nil {
|
||||||
}
|
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
|
||||||
|
|
||||||
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)
|
return commandkit.Success()
|
||||||
if err == nil && written != len(content) {
|
|
||||||
err = io.ErrShortWrite
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
writer.failure = err
|
|
||||||
}
|
|
||||||
return written, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type yamlPreamble struct {
|
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 {
|
func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||||
selected := plan.scopes[0]
|
selected := plan.scopes[0]
|
||||||
name := selected.scope.scope.Name
|
name := selected.scope.scope.Name
|
||||||
|
reporter := invocation.Reporter()
|
||||||
if err := invocation.Context.Err(); err != nil {
|
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 {
|
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{
|
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||||
Operation: Sources,
|
Operation: Sources,
|
||||||
Selection: plan.selection,
|
Selection: plan.selection,
|
||||||
}, selected.input, state)
|
}, selected.input, state)
|
||||||
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
|
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||||
if acquireErr != nil {
|
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 {
|
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 {
|
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()
|
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)
|
document.Sources = append(document.Sources, item)
|
||||||
}
|
}
|
||||||
|
if err := invocation.Context.Err(); err != nil {
|
||||||
|
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
|
||||||
|
}
|
||||||
|
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||||
var report bytes.Buffer
|
var report bytes.Buffer
|
||||||
if plan.format == "yaml" {
|
if plan.format == "yaml" {
|
||||||
if err := encodeYAML(&report, document); err != nil {
|
if err := encodeYAML(&report, document); err != nil {
|
||||||
return configOperationFailure(err, diagnostics, false)
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
writeSourceReportText(&report, document)
|
writeSourceReportText(&report, document)
|
||||||
}
|
}
|
||||||
if err := invocation.Context.Err(); err != nil {
|
return report.Bytes(), 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 writeSourceReportText(writer *bytes.Buffer, document sourceReport) {
|
func writeSourceReportText(writer *bytes.Buffer, document sourceReport) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ckconfig
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
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 {
|
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
|
writtenRecord := false
|
||||||
for _, selected := range plan.scopes {
|
for _, selected := range plan.scopes {
|
||||||
if err := invocation.Context.Err(); err != nil {
|
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 {
|
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{
|
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||||
Operation: Validate,
|
Operation: Validate,
|
||||||
Selection: plan.selection,
|
Selection: plan.selection,
|
||||||
}, selected.input, state)
|
}, selected.input, state)
|
||||||
diagnostics = append(diagnostics, acquired.Diagnostics...)
|
|
||||||
if acquireErr != nil {
|
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 {
|
if err := invocation.Context.Err(); err != nil {
|
||||||
return configOperationFailure(err, diagnostics, writtenRecord)
|
return configFailure("Configuration validation was canceled.", acquired.Explanation, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||||
record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format)
|
record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return configOperationFailure(err, diagnostics, writtenRecord)
|
return err
|
||||||
}
|
}
|
||||||
if plan.format == "yaml" && !writtenRecord {
|
if plan.format == "yaml" && !writtenRecord {
|
||||||
if err := writeConfigReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil {
|
if _, err := io.WriteString(writer, "scopes:\n"); err != nil {
|
||||||
return configDeliveryFailure(err, diagnostics, true)
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := writeConfigReport(invocation.Streams.Out, record); err != nil {
|
_, err = writer.Write(record)
|
||||||
return configDeliveryFailure(err, diagnostics, true)
|
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
|
writtenRecord = true
|
||||||
}
|
}
|
||||||
outcome := commandkit.Success()
|
return commandkit.Success()
|
||||||
outcome.Diagnostics = diagnostics
|
|
||||||
return outcome
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validationSuccessRecord(scope, format string) ([]byte, error) {
|
func validationSuccessRecord(scope, format string) ([]byte, error) {
|
||||||
|
|||||||
@@ -88,26 +88,33 @@ type Suite[I, S any] struct {
|
|||||||
// CheckInput validates selected inspection, requirements, validation, smoke,
|
// CheckInput validates selected inspection, requirements, validation, smoke,
|
||||||
// or catalog input before state creation.
|
// or catalog input before state creation.
|
||||||
CheckInput func(ckconfig.Selection, I) error
|
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)
|
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)
|
InspectProfile func(context.Context, ProfileRequest, I, S) (commandkit.Acquired[*promptkit.ProfileInspection], error)
|
||||||
// InspectRequirements inspects native PromptKit requirements after input and
|
// InspectRequirements inspects native PromptKit requirements after input and
|
||||||
// state preparation. It is optional; a nil callback omits
|
// state preparation. Its acquisition carries independent loader diagnostics
|
||||||
// `promptkit requirements show` without disabling validation or other
|
// and terminal context alongside any native inspection result. It is
|
||||||
// capabilities.
|
// 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)
|
InspectRequirements func(context.Context, RequirementsRequest, I, S) (commandkit.Acquired[*promptkit.RequirementInspection], error)
|
||||||
// ValidateRequest validates a concrete PromptKit request after input and
|
// ValidateRequest validates a concrete PromptKit request after input and
|
||||||
// state preparation. It is optional; a nil callback omits
|
// state preparation. It is optional; a nil callback omits
|
||||||
// `promptkit requirements validate` without disabling discovery or other
|
// `promptkit requirements validate` without disabling discovery or other
|
||||||
// capabilities. The acquisition carries diagnostics and an empty value on
|
// capabilities. The acquisition carries independent diagnostics, terminal
|
||||||
// success; PromptKit errors are returned unchanged where supported.
|
// 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)
|
ValidateRequest func(context.Context, ValidationRequest, I, S) (commandkit.Acquired[struct{}], error)
|
||||||
// SmokeTest runs one concrete native smoke-test operation after input and
|
// SmokeTest runs one concrete native smoke-test operation after input and
|
||||||
// state preparation. It is optional; nil omits only the smoke-test command.
|
// state preparation. It is optional; nil omits only the smoke-test command.
|
||||||
// The acquisition retains a native report even when the callback also
|
// The acquisition retains a native report, independent diagnostics, and
|
||||||
// returns an error. For `test all`, CommandKit calls the captured callback
|
// terminal context even when the callback also returns an error. For
|
||||||
// sequentially for each selected kind after preparing input and state once.
|
// `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)
|
SmokeTest func(context.Context, SmokeRequest, I, S) (commandkit.Acquired[*promptkit.SmokeTestReport], error)
|
||||||
// ListPrompts lists prompt identities after input and state preparation.
|
// ListPrompts lists prompt identities after input and state preparation.
|
||||||
// It is optional independently of the other catalogs; nil omits only this
|
// 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
|
// PromptKit unchanged; the loader receives the seed's original configuration
|
||||||
// selection. A nil builder forwards the seed request unchanged. A builder
|
// selection. A nil builder forwards the seed request unchanged. A builder
|
||||||
// failure prevents engine loading and preserves its supported error identity.
|
// failure prevents engine loading and preserves its supported error identity.
|
||||||
// Native validation errors are returned with supported identity, and
|
// Native validation errors supply terminal explanations and are returned with
|
||||||
// configuration diagnostics from engine loading remain in the acquisition. A
|
// supported identity, and
|
||||||
// nil loader is retained as a suite wiring issue and does not install a
|
// configuration diagnostics and terminal context from engine loading remain
|
||||||
// validation callback. Callback references and objects remain caller-owned.
|
// 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](
|
func WithRequestValidation[I, S any](
|
||||||
suite Suite[I, S],
|
suite Suite[I, S],
|
||||||
load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error),
|
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)
|
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 {
|
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 {
|
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 {
|
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 {
|
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
|
return suite
|
||||||
}
|
}
|
||||||
|
|
||||||
type engineLoader[I, S any] func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)
|
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
|
// ConfigScope adapts PromptKit's captured configuration inspection to the
|
||||||
// standard configuration commands, including native comparison when the scope
|
// standard configuration commands, including native comparison when the scope
|
||||||
// declares Diff. The loader is not called during declaration construction or
|
// declares Diff. The loader is not called during declaration construction or
|
||||||
// installation; it is called only when a config operation selects the
|
// installation; it is called only when a config operation selects the
|
||||||
// promptkit scope, with that operation's input and shared state. PromptKit
|
// promptkit scope, with that operation's input and shared state. PromptKit
|
||||||
// diagnostics and supported error identity are returned through ckconfig's
|
// independent diagnostics, terminal context, and supported error identity are
|
||||||
// acquisition and comparison contracts.
|
// 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] {
|
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]{
|
scope := ckconfig.Scope[I, S]{
|
||||||
Name: "promptkit",
|
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) {
|
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 {
|
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]{
|
return commandkit.Acquired[ckconfig.View]{
|
||||||
Value: ckconfig.View{
|
Value: ckconfig.View{
|
||||||
@@ -224,7 +255,8 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
|
|||||||
Sources: inspection,
|
Sources: inspection,
|
||||||
Explanation: inspection,
|
Explanation: inspection,
|
||||||
},
|
},
|
||||||
Diagnostics: diagnostics,
|
Diagnostics: messages.diagnostics,
|
||||||
|
Explanation: messages.explanation,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
scope.Compare = func(ctx context.Context, request ckconfig.ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
|
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)
|
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)
|
_, leftInspection, messages, err := loadPromptKitEngine(ctx, request.Left.Selection, request.Left.Input, state, "left", load)
|
||||||
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
|
|
||||||
if err != nil {
|
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 {
|
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)
|
_, rightInspection, rightMessages, err := loadPromptKitEngine(ctx, request.Right.Selection, request.Right.Input, state, "right", load)
|
||||||
diagnostics = append(diagnostics, rightDiagnostics...)
|
messages = appendPromptKitAcquisition(messages, rightMessages)
|
||||||
if err != nil {
|
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 {
|
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)
|
differences, err := leftInspection.Diff(rightInspection)
|
||||||
|
messages = messages.withOperationError(err)
|
||||||
if err != nil {
|
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
|
return scope
|
||||||
}
|
}
|
||||||
@@ -280,8 +312,9 @@ func promptKitContextError(ctx context.Context) error {
|
|||||||
// shared invocation state to the loader before calling PromptKit's public
|
// shared invocation state to the loader before calling PromptKit's public
|
||||||
// listing or inspection method. Applications can narrow, wrap, or replace these
|
// listing or inspection method. Applications can narrow, wrap, or replace these
|
||||||
// callbacks before Command captures them. A nil loader is retained as an
|
// callbacks before Command captures them. A nil loader is retained as an
|
||||||
// adapter issue. Each callback returns loader diagnostics with the native
|
// adapter issue. Each callback returns independent loader diagnostics and
|
||||||
// operation's value and error.
|
// 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] {
|
func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) Suite[I, S] {
|
||||||
if load == nil {
|
if load == nil {
|
||||||
return Suite[I, S]{issues: []error{errors.New("PromptKit engine loader is 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]{
|
return Suite[I, S]{
|
||||||
ListPrompts: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
|
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 {
|
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)
|
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) {
|
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 {
|
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)
|
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) {
|
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 {
|
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)
|
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) {
|
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 {
|
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...)
|
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) {
|
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 {
|
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)
|
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) {
|
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 {
|
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)
|
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) {
|
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 {
|
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)
|
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) {
|
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 {
|
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)
|
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,
|
state S,
|
||||||
side string,
|
side string,
|
||||||
load engineLoader[I, S],
|
load engineLoader[I, S],
|
||||||
) (*promptkit.Engine, promptkit.ConfigurationInspection, []commandkit.Diagnostic, error) {
|
) (*promptkit.Engine, promptkit.ConfigurationInspection, promptKitAcquisition, error) {
|
||||||
engine, err := load(ctx, selection, input, state)
|
engine, err := load(ctx, selection, input, state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var loadErr *configkit.LoadError
|
var loadErr *configkit.LoadError
|
||||||
if errors.As(err, &loadErr) {
|
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 {
|
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()
|
inspection, err := engine.InspectConfiguration()
|
||||||
|
messages := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics())
|
||||||
|
messages = messages.withOperationError(err)
|
||||||
if err != nil {
|
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 ctx != nil {
|
||||||
if err := ctx.Err(); err != 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 {
|
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
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) promptKitAcquisition {
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return nil
|
return promptKitAcquisition{}
|
||||||
}
|
|
||||||
severity := commandkit.SeverityWarning
|
|
||||||
for _, record := range records {
|
|
||||||
if record.Severity == configkit.SeverityError {
|
|
||||||
severity = commandkit.SeverityError
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Snapshot provider-owned slices before deferring their report to invocation.
|
// Snapshot provider-owned slices before deferring their report to invocation.
|
||||||
sources = append([]configkit.SourceInfo(nil), sources...)
|
sources = append([]configkit.SourceInfo(nil), sources...)
|
||||||
@@ -408,9 +453,32 @@ func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, re
|
|||||||
}
|
}
|
||||||
records[index].Related = append([]configkit.Location(nil), records[index].Related...)
|
records[index].Related = append([]configkit.Location(nil), records[index].Related...)
|
||||||
}
|
}
|
||||||
return []commandkit.Diagnostic{{
|
var warnings, failures []configkit.Diagnostic
|
||||||
Severity: severity,
|
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 {
|
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)
|
heading := "Configuration scope " + strconv.Quote(scope)
|
||||||
if side != "" {
|
if side != "" {
|
||||||
heading += " (" + side + ")"
|
heading += " (" + side + ")"
|
||||||
@@ -429,6 +497,4 @@ func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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: [")))
|
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid smoke configuration", []byte("broken: [")))
|
||||||
if loadErr == nil {
|
if loadErr == nil {
|
||||||
t.Fatal("invalid configuration unexpectedly loaded")
|
t.Fatal("invalid configuration unexpectedly loaded")
|
||||||
@@ -249,7 +249,7 @@ func TestWithSmokeTestingRetainsLoaderDiagnosticsAndErrorIdentity(t *testing.T)
|
|||||||
}, nil)
|
}, nil)
|
||||||
acquired, err := suite.SmokeTest(context.Background(), SmokeRequest{}, 0, 0)
|
acquired, err := suite.SmokeTest(context.Background(), SmokeRequest{}, 0, 0)
|
||||||
var retained *configkit.LoadError
|
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)
|
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: [")))
|
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid right prompt config", []byte("broken: [")))
|
||||||
if loadErr == nil {
|
if loadErr == nil {
|
||||||
t.Fatal("invalid right configuration unexpectedly loaded")
|
t.Fatal("invalid right configuration unexpectedly loaded")
|
||||||
@@ -415,12 +415,12 @@ func TestConfigScopeCompareRetainsRightLoadDiagnosticsAndErrorIdentity(t *testin
|
|||||||
|
|
||||||
acquired, err := scope.Compare(context.Background(), request, struct{}{})
|
acquired, err := scope.Compare(context.Background(), request, struct{}{})
|
||||||
var retained *configkit.LoadError
|
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)
|
t.Fatalf("right failure = (%#v, %v), calls=%#v", acquired, err, calls)
|
||||||
}
|
}
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
if writeErr := acquired.Diagnostics[0].Write(&output); writeErr != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (right):`) {
|
if writeErr := acquired.Explanation.Details[0](&output); writeErr != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (right):`) {
|
||||||
t.Fatalf("right diagnostic = %q, %v", output.String(), writeErr)
|
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: [")))
|
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid requirements configuration", []byte("broken: [")))
|
||||||
if loadErr == nil {
|
if loadErr == nil {
|
||||||
t.Fatal("invalid configuration unexpectedly loaded")
|
t.Fatal("invalid configuration unexpectedly loaded")
|
||||||
@@ -544,11 +544,8 @@ func TestFromEngineRequirementsRetainLoadDiagnosticsAndSelectionErrors(t *testin
|
|||||||
})
|
})
|
||||||
acquired, err := loadFailure.InspectRequirements(context.Background(), RequirementsRequest{}, 0, 0)
|
acquired, err := loadFailure.InspectRequirements(context.Background(), RequirementsRequest{}, 0, 0)
|
||||||
var retained *configkit.LoadError
|
var retained *configkit.LoadError
|
||||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 || acquired.Value != nil {
|
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 diagnostics", acquired, err)
|
t.Fatalf("requirements load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err)
|
||||||
}
|
|
||||||
if acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
|
||||||
t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nilEngine := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
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: [")))
|
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid catalog configuration", []byte("broken: [")))
|
||||||
if loadErr == nil {
|
if loadErr == nil {
|
||||||
t.Fatal("invalid configuration unexpectedly loaded")
|
t.Fatal("invalid configuration unexpectedly loaded")
|
||||||
@@ -666,42 +663,42 @@ func TestFromEngineCatalogsRetainLoadDiagnosticsAndErrorIdentity(t *testing.T) {
|
|||||||
{name: "prompts", call: func() (int, error) {
|
{name: "prompts", call: func() (int, error) {
|
||||||
acquired, err := suite.ListPrompts(context.Background(), CatalogRequest{}, 0, 0)
|
acquired, err := suite.ListPrompts(context.Background(), CatalogRequest{}, 0, 0)
|
||||||
var retained *configkit.LoadError
|
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)
|
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) {
|
{name: "profiles", call: func() (int, error) {
|
||||||
acquired, err := suite.ListProfiles(context.Background(), CatalogRequest{}, 0, 0)
|
acquired, err := suite.ListProfiles(context.Background(), CatalogRequest{}, 0, 0)
|
||||||
var retained *configkit.LoadError
|
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)
|
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) {
|
{name: "backends", call: func() (int, error) {
|
||||||
acquired, err := suite.ListBackends(context.Background(), CatalogRequest{}, 0, 0)
|
acquired, err := suite.ListBackends(context.Background(), CatalogRequest{}, 0, 0)
|
||||||
var retained *configkit.LoadError
|
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)
|
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) {
|
{name: "namespaces", call: func() (int, error) {
|
||||||
acquired, err := suite.ListNamespaces(context.Background(), NamespaceRequest{}, 0, 0)
|
acquired, err := suite.ListNamespaces(context.Background(), NamespaceRequest{}, 0, 0)
|
||||||
var retained *configkit.LoadError
|
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)
|
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) {
|
{name: "collections", call: func() (int, error) {
|
||||||
acquired, err := suite.ListCollections(context.Background(), ckconfig.Selection{}, 0, 0)
|
acquired, err := suite.ListCollections(context.Background(), ckconfig.Selection{}, 0, 0)
|
||||||
var retained *configkit.LoadError
|
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)
|
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) {
|
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: [")))
|
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid PromptKit config", []byte("broken: [")))
|
||||||
if loadErr == nil {
|
if loadErr == nil {
|
||||||
t.Fatal("invalid configuration unexpectedly loaded")
|
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)
|
acquired, err := suite.InspectProfile(context.Background(), ProfileRequest{ID: "default"}, 0, 0)
|
||||||
var retained *configkit.LoadError
|
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.Fatalf("load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err)
|
t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err)
|
||||||
}
|
|
||||||
if acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
|
||||||
t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,25 +817,55 @@ func TestPromptKitDiagnosticsRemainDeferredAndPropagateWriterErrors(t *testing.T
|
|||||||
Severity: configkit.SeverityWarning,
|
Severity: configkit.SeverityWarning,
|
||||||
Message: "safe warning",
|
Message: "safe warning",
|
||||||
}}
|
}}
|
||||||
diagnostics := promptKitDiagnostics("promptkit", "", sources, records)
|
messages := promptKitDiagnostics("promptkit", "", sources, records)
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Severity != commandkit.SeverityWarning {
|
if len(messages.diagnostics) != 1 || messages.diagnostics[0].Severity != commandkit.SeverityWarning || len(messages.explanation.Details) != 0 {
|
||||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
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)
|
t.Fatalf("diagnostic write error = %v", err)
|
||||||
}
|
}
|
||||||
var output bytes.Buffer
|
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)
|
t.Fatalf("diagnostic text = %q, %v", output.String(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sideDiagnostics := promptKitDiagnostics("promptkit", "left", sources, records)
|
sideDiagnostics := promptKitDiagnostics("promptkit", "left", sources, records)
|
||||||
output.Reset()
|
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)
|
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 }
|
type failingDiagnosticWriter struct{ err error }
|
||||||
|
|
||||||
func (writer failingDiagnosticWriter) Write([]byte) (int, error) { return 0, writer.err }
|
func (writer failingDiagnosticWriter) Write([]byte) (int, error) { return 0, writer.err }
|
||||||
|
|||||||
@@ -134,11 +134,11 @@ func newCatalogListCommand[I, S any](suite Suite[I, S], name string) commandkit.
|
|||||||
return &(*input).captured
|
return &(*input).captured
|
||||||
})
|
})
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
|
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }),
|
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Catalog format: yaml."}, stringDefault("yaml"), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Catalog format: yaml.", Completion: completionChoices("yaml")}, stringDefault("yaml"), parseDefinitionString,
|
||||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
||||||
)
|
)
|
||||||
if name == "prompts" || name == "profiles" || name == "backends" {
|
if name == "prompts" || name == "profiles" || name == "backends" {
|
||||||
@@ -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 {
|
Run: func(invocation commandkit.Invocation, input *catalogInput[I]) commandkit.Outcome {
|
||||||
switch name {
|
switch name {
|
||||||
case "prompts":
|
case "prompts":
|
||||||
return runCatalogList(invocation, suite, input, suite.ListPrompts, encodePromptCatalog)
|
return runCatalogList(invocation, suite, input, name, suite.ListPrompts, encodePromptCatalog)
|
||||||
case "profiles":
|
case "profiles":
|
||||||
return runCatalogList(invocation, suite, input, suite.ListProfiles, encodeProfileCatalog)
|
return runCatalogList(invocation, suite, input, name, suite.ListProfiles, encodeProfileCatalog)
|
||||||
case "backends":
|
case "backends":
|
||||||
return runCatalogList(invocation, suite, input, suite.ListBackends, encodeProfileCatalog)
|
return runCatalogList(invocation, suite, input, name, suite.ListBackends, encodeProfileCatalog)
|
||||||
case "namespaces":
|
case "namespaces":
|
||||||
list := func(ctx context.Context, request CatalogRequest, value I, state S) (commandkit.Acquired[[]string], error) {
|
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 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":
|
case "collections":
|
||||||
list := func(ctx context.Context, request CatalogRequest, value I, state S) (commandkit.Acquired[[]promptkit.CollectionInfo], error) {
|
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 suite.ListCollections(ctx, request.Selection, value, state)
|
||||||
}
|
}
|
||||||
return runCatalogList(invocation, suite, input, list, encodeCollectionCatalog)
|
return runCatalogList(invocation, suite, input, name, list, encodeCollectionCatalog)
|
||||||
default:
|
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))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -256,13 +256,13 @@ func newDefinitionCommand[I, S any](suite Suite[I, S], name string, prompt bool)
|
|||||||
return &(*input).captured
|
return &(*input).captured
|
||||||
})
|
})
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
|
func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **definitionInput[I], value commandkit.Supplied[string]) {
|
func(input **definitionInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Overlay = value
|
(*input).selection.Overlay = value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: text or yaml."}, stringDefault("text"), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: text or yaml.", Completion: completionChoices("text", "yaml")}, stringDefault("text"), parseDefinitionString,
|
||||||
func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
||||||
)
|
)
|
||||||
spec := commandkit.CommandSpec[*definitionInput[I]]{
|
spec := commandkit.CommandSpec[*definitionInput[I]]{
|
||||||
@@ -302,11 +302,11 @@ func newRequirementsCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
|||||||
return &(*input).captured
|
return &(*input).captured
|
||||||
})
|
})
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Config = value
|
(*input).selection.Config = value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Overlay = value
|
(*input).selection.Overlay = value
|
||||||
}),
|
}),
|
||||||
@@ -318,7 +318,7 @@ func newRequirementsCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
|||||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).inspection.SessionID = value.Value
|
(*input).inspection.SessionID = value.Value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: yaml."}, stringDefault("yaml"), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: yaml.", Completion: completionChoices("yaml")}, stringDefault("yaml"), parseDefinitionString,
|
||||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
func(input **requirementsInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
||||||
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "var", Usage: "Caller variable assignment KEY=VALUE."}, []variableAssignment(nil), parseVariableAssignment,
|
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "var", Usage: "Caller variable assignment KEY=VALUE."}, []variableAssignment(nil), parseVariableAssignment,
|
||||||
func(input **requirementsInput[I], value commandkit.Supplied[[]variableAssignment]) {
|
func(input **requirementsInput[I], value commandkit.Supplied[[]variableAssignment]) {
|
||||||
@@ -385,12 +385,13 @@ func runCatalogList[T any, I, S any](
|
|||||||
invocation commandkit.Invocation,
|
invocation commandkit.Invocation,
|
||||||
suite Suite[I, S],
|
suite Suite[I, S],
|
||||||
input *catalogInput[I],
|
input *catalogInput[I],
|
||||||
|
catalog string,
|
||||||
list func(context.Context, CatalogRequest, I, S) (commandkit.Acquired[T], error),
|
list func(context.Context, CatalogRequest, I, S) (commandkit.Acquired[T], error),
|
||||||
encode func(T) ([]byte, error),
|
encode func(T) ([]byte, error),
|
||||||
) commandkit.Outcome {
|
) commandkit.Outcome {
|
||||||
if invocation.Context != nil {
|
if invocation.Context != nil {
|
||||||
if err := invocation.Context.Err(); err != 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
|
var state S
|
||||||
@@ -399,30 +400,29 @@ func runCatalogList[T any, I, S any](
|
|||||||
}
|
}
|
||||||
if invocation.Context != nil {
|
if invocation.Context != nil {
|
||||||
if err := invocation.Context.Err(); err != 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)
|
acquired, err := list(invocation.Context, CatalogRequest{Selection: input.selection, Filter: input.filter}, input.selected, state)
|
||||||
|
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||||
if err != nil {
|
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 invocation.Context != nil {
|
||||||
if err := invocation.Context.Err(); err != 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)
|
return renderPreparedPromptReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||||
if err != nil {
|
return encode(acquired.Value)
|
||||||
return definitionFailure(err, acquired.Diagnostics)
|
})
|
||||||
}
|
|
||||||
return writePromptReport(invocation, output, acquired.Diagnostics)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDefinitionInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *definitionInput[I], prompt bool) commandkit.Outcome {
|
func runDefinitionInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *definitionInput[I], prompt bool) commandkit.Outcome {
|
||||||
if invocation.Context != nil {
|
if invocation.Context != nil {
|
||||||
if err := invocation.Context.Err(); err != nil {
|
if err := invocation.Context.Err(); err != nil {
|
||||||
return definitionFailure(err, nil)
|
return definitionFailure("PromptKit inspection was canceled.", commandkit.Explanation{}, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var state S
|
var state S
|
||||||
@@ -431,41 +431,43 @@ func runDefinitionInspection[I, S any](invocation commandkit.Invocation, suite S
|
|||||||
}
|
}
|
||||||
if invocation.Context != nil {
|
if invocation.Context != nil {
|
||||||
if err := invocation.Context.Err(); err != nil {
|
if err := invocation.Context.Err(); err != nil {
|
||||||
return definitionFailure(err, nil)
|
return definitionFailure("PromptKit inspection was canceled.", commandkit.Explanation{}, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if prompt {
|
if prompt {
|
||||||
request := PromptRequest{Selection: input.selection, ID: input.id}
|
request := PromptRequest{Selection: input.selection, ID: input.id}
|
||||||
acquired, err := suite.InspectPrompt(invocation.Context, request, input.selected, state)
|
acquired, err := suite.InspectPrompt(invocation.Context, request, input.selected, state)
|
||||||
|
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||||
if err != nil {
|
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 {
|
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 invocation.Context != nil {
|
||||||
if err := invocation.Context.Err(); err != 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}
|
request := ProfileRequest{Selection: input.selection, ID: input.id}
|
||||||
acquired, err := suite.InspectProfile(invocation.Context, request, input.selected, state)
|
acquired, err := suite.InspectProfile(invocation.Context, request, input.selected, state)
|
||||||
|
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||||
if err != nil {
|
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 {
|
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 invocation.Context != nil {
|
||||||
if err := invocation.Context.Err(); err != 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 {
|
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 {
|
func runRequirementsInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *requirementsInput[I]) commandkit.Outcome {
|
||||||
if invocation.Context != nil {
|
if invocation.Context != nil {
|
||||||
if err := invocation.Context.Err(); err != 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
|
var state S
|
||||||
@@ -633,56 +635,69 @@ func runRequirementsInspection[I, S any](invocation commandkit.Invocation, suite
|
|||||||
}
|
}
|
||||||
if invocation.Context != nil {
|
if invocation.Context != nil {
|
||||||
if err := invocation.Context.Err(); err != 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}
|
request := RequirementsRequest{Selection: input.selection, Inspection: input.inspection}
|
||||||
acquired, err := suite.InspectRequirements(invocation.Context, request, input.selected, state)
|
acquired, err := suite.InspectRequirements(invocation.Context, request, input.selected, state)
|
||||||
|
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||||
if err != nil {
|
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 {
|
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 invocation.Context != nil {
|
||||||
if err := invocation.Context.Err(); err != 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))
|
return renderPreparedPromptReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||||
if err != nil {
|
return encodePromptYAMLBytes(requirementsReportFromInspection(acquired.Value))
|
||||||
return definitionFailure(err, acquired.Diagnostics)
|
})
|
||||||
}
|
|
||||||
return writePromptReport(invocation, output, acquired.Diagnostics)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderPromptReport(invocation commandkit.Invocation, report promptInspectionReport, format string, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
func renderPromptReport(invocation commandkit.Invocation, inspection *promptkit.PromptInspection, format string, explanation commandkit.Explanation) commandkit.Outcome {
|
||||||
var output bytes.Buffer
|
return renderPreparedPromptReport(invocation, explanation, func() ([]byte, error) {
|
||||||
|
report := promptReportFromInspection(inspection)
|
||||||
if format == "yaml" {
|
if format == "yaml" {
|
||||||
if err := encodePromptYAML(&output, report); err != nil {
|
return encodePromptYAMLBytes(report)
|
||||||
return definitionFailure(err, diagnostics)
|
|
||||||
}
|
}
|
||||||
} else {
|
return []byte(formatPromptText(report)), nil
|
||||||
output.WriteString(formatPromptText(report))
|
})
|
||||||
}
|
|
||||||
return writePromptReport(invocation, output.Bytes(), diagnostics)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderProfileReport(invocation commandkit.Invocation, report profileInspectionReport, format string, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
func renderProfileReport(invocation commandkit.Invocation, inspection *promptkit.ProfileInspection, format string, explanation commandkit.Explanation) commandkit.Outcome {
|
||||||
var output bytes.Buffer
|
return renderPreparedPromptReport(invocation, explanation, func() ([]byte, error) {
|
||||||
|
report := profileReportFromInspection(inspection)
|
||||||
if format == "yaml" {
|
if format == "yaml" {
|
||||||
if err := encodePromptYAML(&output, report); err != nil {
|
return encodePromptYAMLBytes(report)
|
||||||
return definitionFailure(err, diagnostics)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
text, err := formatProfileText(report)
|
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 {
|
if err != nil {
|
||||||
return definitionFailure(err, diagnostics)
|
return err
|
||||||
}
|
}
|
||||||
output.WriteString(text)
|
if canceled = promptKitContextError(invocation.Context); canceled != nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return writePromptReport(invocation, output.Bytes(), diagnostics)
|
_, err = writer.Write(output)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if canceled != nil {
|
||||||
|
return definitionFailure("PromptKit report preparation was canceled.", explanation, canceled)
|
||||||
|
}
|
||||||
|
return commandkit.Success()
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatPromptText(report promptInspectionReport) string {
|
func formatPromptText(report promptInspectionReport) string {
|
||||||
@@ -771,30 +786,33 @@ func encodePromptYAMLBytes(value any) ([]byte, error) {
|
|||||||
return output.Bytes(), nil
|
return output.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writePromptReport(invocation commandkit.Invocation, output []byte, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
func queuePromptDiagnostics(reporter *commandkit.Reporter, diagnostics []commandkit.Diagnostic) {
|
||||||
if invocation.Context != nil {
|
deferred := reporter.AtEnd()
|
||||||
if err := invocation.Context.Err(); err != nil {
|
for _, diagnostic := range diagnostics {
|
||||||
return definitionFailure(err, 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 {
|
func definitionFailure(message string, explanation commandkit.Explanation, cause error) commandkit.Outcome {
|
||||||
outcome := commandkit.Failure(cause)
|
outcome := commandkit.Failure(message)
|
||||||
outcome.Diagnostics = diagnostics
|
if explanation.Message != "" || len(explanation.Details) > 0 {
|
||||||
|
outcome = outcome.WithExplanation(explanation.Message, explanation.Details...)
|
||||||
|
}
|
||||||
|
if cause != nil {
|
||||||
|
outcome = outcome.WithCause(cause)
|
||||||
|
}
|
||||||
return outcome
|
return outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
"go.yaml.in/yaml/v4"
|
"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()
|
t.Helper()
|
||||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, commands...)
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, declarations...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ func TestCatalogCommandTreesAreIndependentAndOmitShow(t *testing.T) {
|
|||||||
app := newPromptTestApp(t, test.cmd)
|
app := newPromptTestApp(t, test.cmd)
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"help", "promptkit", "list"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
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())
|
t.Fatalf("catalog-only help = (%#v, %q)", outcome, output.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -701,41 +701,39 @@ func TestCatalogRejectsOperandsAndUnavailableSelectors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCatalogFailuresRetainDiagnosticsAndDeliveryState(t *testing.T) {
|
func TestCatalogFailureReportsWarningAndNativeExplanationSeparately(t *testing.T) {
|
||||||
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
diagnostic := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "catalog warning")
|
||||||
_, 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
|
|
||||||
}
|
|
||||||
providerErr := errors.New("catalog provider failed")
|
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) {
|
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})
|
var output, diagnosticOutput bytes.Buffer
|
||||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, providerErr) || !hasCatalogWarning(outcome.Diagnostics) {
|
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)
|
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())
|
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) {
|
canceled := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ListPrompts: func(context.Context, CatalogRequest, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
|
||||||
cancel()
|
cancel()
|
||||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "briefing", Version: testStringPointer("1")}}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "briefing", Version: testStringPointer("1")}}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
||||||
}}))
|
}}))
|
||||||
var output bytes.Buffer
|
output.Reset()
|
||||||
outcome = canceled.Execute(ctx, []string{"promptkit", "list", "prompts"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
diagnosticOutput.Reset()
|
||||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !hasCatalogWarning(outcome.Diagnostics) || output.Len() != 0 {
|
outcome = canceled.Execute(ctx, []string{"promptkit", "list", "prompts", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||||
t.Fatalf("post-provider cancellation outcome = %#v output=%q", outcome, output.String())
|
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")
|
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
|
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})
|
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)
|
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) {
|
func TestCommandUsesInstalledNamespaceCollisionChecks(t *testing.T) {
|
||||||
command := Command(Suite[struct{}, struct{}]{InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
command := Command(Suite[struct{}, struct{}]{InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||||
return commandkit.Acquired[*promptkit.PromptInspection]{}, nil
|
return commandkit.Acquired[*promptkit.PromptInspection]{}, nil
|
||||||
@@ -1008,7 +1089,7 @@ func TestNativeLookupErrorsAndBlankIDsReachCallbacks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
output.Reset()
|
output.Reset()
|
||||||
diagnosticOutput.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") {
|
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())
|
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")
|
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})
|
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)
|
t.Fatalf("report delivery outcome = %#v calls=%d", outcome, calls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
integrations/ckprompt/completion.go
Normal file
20
integrations/ckprompt/completion.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package ckprompt
|
||||||
|
|
||||||
|
import commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
|
|
||||||
|
func completionChoices(values ...string) commandkit.CompletionHint {
|
||||||
|
choices := make([]commandkit.CompletionChoice, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
choices[i].Value = value
|
||||||
|
}
|
||||||
|
return commandkit.CompleteChoices(choices...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func smokeKindCompletion() commandkit.CompletionHint {
|
||||||
|
kinds := aggregateSmokeKinds()
|
||||||
|
values := make([]string, 0, len(kinds)+1)
|
||||||
|
for _, kind := range kinds {
|
||||||
|
values = append(values, string(kind))
|
||||||
|
}
|
||||||
|
return completionChoices(append(values, "all")...)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"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) {
|
func TestPublicConfigurationAndDefinitionFamiliesRemainIndependent(t *testing.T) {
|
||||||
inputs, err := promptkit.FilesystemInputs("composition fixture", fstest.MapFS{
|
inputs, err := promptkit.FilesystemInputs("composition fixture", fstest.MapFS{
|
||||||
"config/prompts.yml": &fstest.MapFile{Data: []byte(`prompts:
|
"config/prompts.yml": &fstest.MapFile{Data: []byte(`prompts:
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
// Package ckprompt provides optional PromptKit configuration comparison,
|
// Package ckprompt provides optional PromptKit configuration comparison,
|
||||||
// prompt catalogs, named inspections, requirements discovery and validation,
|
// prompt catalogs, named inspections, requirements discovery and validation,
|
||||||
// and native smoke testing for CommandKit. Importing ckprompt installs neither
|
// and native smoke testing for CommandKit. Its command factories return
|
||||||
// family; callers compose its declarations with core and ckconfig explicitly.
|
// 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
|
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 backends --campaign spring
|
||||||
go run . promptkit list namespaces --campaign spring
|
go run . promptkit list namespaces --campaign spring
|
||||||
go run . promptkit list collections --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 list prompts --namespace field-notes --campaign spring
|
||||||
go run . promptkit show prompt briefing --campaign spring --format yaml
|
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 show profile spring --campaign spring
|
||||||
go run . promptkit requirements show briefing --campaign spring
|
go run . promptkit requirements show briefing --campaign spring
|
||||||
go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml
|
go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml
|
||||||
@@ -24,10 +27,35 @@ 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 --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 requirements validate briefing --campaign spring
|
||||||
go run . promptkit test text_inference --campaign spring --prompt briefing --profile 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 . promptkit test all --campaign spring --prompt briefing --profile spring
|
||||||
|
go run . pk --hlep
|
||||||
go run . session restore session-42 --campaign spring
|
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 example installs `Completion("composed-example")`. Build it as
|
||||||
|
`go build -o composed-example .`. Initialize `bash-completion` first if using
|
||||||
|
Bash, then load a shell script as described in the
|
||||||
|
[CLI reference](../../../../docs/cli.md#shell-completion). Try completion after
|
||||||
|
`composed-example pk show prompt briefing --fmt=` or after the filename prefix
|
||||||
|
in `composed-example config show --config=fixtures/`. The already-entered `pk`
|
||||||
|
alias selects the PromptKit command context without loading an engine.
|
||||||
|
|
||||||
|
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
|
The sources and session store are small in-memory fixtures. `--campaign` is a
|
||||||
typed selector shared by the app and PromptKit configuration scopes. Their
|
typed selector shared by the app and PromptKit configuration scopes. Their
|
||||||
`ckconfig.Suite[*inspectionInput, *runCache]` uses `NewInput` to initialize each
|
`ckconfig.Suite[*inspectionInput, *runCache]` uses `NewInput` to initialize each
|
||||||
@@ -92,3 +120,12 @@ required `topic` artifact is not supplied. The builder defaults variables from
|
|||||||
the selected campaign but leaves artifact bindings to the operator. The
|
the selected campaign but leaves artifact bindings to the operator. The
|
||||||
`requirements show` commands remain discovery reports: partial discovery can
|
`requirements show` commands remain discovery reports: partial discovery can
|
||||||
succeed even when a concrete request would fail validation.
|
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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -65,6 +66,8 @@ type session struct {
|
|||||||
Summary string
|
Summary string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var errSessionNotFound = errors.New("saved session was not found")
|
||||||
|
|
||||||
func campaignSelector() commandkit.Flag[*inspectionInput] {
|
func campaignSelector() commandkit.Flag[*inspectionInput] {
|
||||||
return commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Usage: "Select campaign inputs."}, (*string)(nil), parseString,
|
return commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Usage: "Select campaign inputs."}, (*string)(nil), parseString,
|
||||||
func(input **inspectionInput, value commandkit.Supplied[string]) { (*input).Campaign = value })
|
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) {
|
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 {
|
if client == nil {
|
||||||
client = &exampleLLMClient{}
|
client = &exampleLLMClient{}
|
||||||
}
|
}
|
||||||
@@ -210,7 +221,7 @@ func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
|
|||||||
stateFactory := func() *runCache { return &runCache{llmClient: client} }
|
stateFactory := func() *runCache { return &runCache{llmClient: client} }
|
||||||
|
|
||||||
appScope := ckconfig.FromConfigKit("app", "Application settings.", loadApplicationConfig)
|
appScope := ckconfig.FromConfigKit("app", "Application settings.", loadApplicationConfig)
|
||||||
engineScope := ckprompt.ConfigScope(loadPromptEngine)
|
engineScope := ckprompt.ConfigScope(engineLoader)
|
||||||
configCommand := ckconfig.Command(ckconfig.Suite[*inspectionInput, *runCache]{
|
configCommand := ckconfig.Command(ckconfig.Suite[*inspectionInput, *runCache]{
|
||||||
NewInput: inputFactory,
|
NewInput: inputFactory,
|
||||||
NewState: stateFactory,
|
NewState: stateFactory,
|
||||||
@@ -227,12 +238,15 @@ func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
|
|||||||
Scopes: []ckconfig.Scope[*inspectionInput, *runCache]{appScope, engineScope},
|
Scopes: []ckconfig.Scope[*inspectionInput, *runCache]{appScope, engineScope},
|
||||||
})
|
})
|
||||||
|
|
||||||
definitions := ckprompt.FromEngine(loadPromptEngine)
|
definitions := ckprompt.FromEngine(engineLoader)
|
||||||
definitions.NewInput = inputFactory
|
definitions.NewInput = inputFactory
|
||||||
definitions.NewState = stateFactory
|
definitions.NewState = stateFactory
|
||||||
definitions.Selectors = []commandkit.Flag[*inspectionInput]{campaignSelector()}
|
definitions.Selectors = []commandkit.Flag[*inspectionInput]{campaignSelector()}
|
||||||
definitions = ckprompt.WithRequestValidation(definitions, loadPromptEngine, buildValidationRequest)
|
definitions = ckprompt.WithRequestValidation(definitions, engineLoader, buildValidationRequest)
|
||||||
definitions = ckprompt.WithSmokeTesting(definitions, loadPromptEngine, nil)
|
definitions = ckprompt.WithSmokeTesting(definitions, engineLoader, nil)
|
||||||
|
if smokeTest != nil {
|
||||||
|
definitions.SmokeTest = smokeTest
|
||||||
|
}
|
||||||
definitionCommand := ckprompt.Command(definitions)
|
definitionCommand := ckprompt.Command(definitions)
|
||||||
|
|
||||||
store := map[string]session{
|
store := map[string]session{
|
||||||
@@ -246,36 +260,46 @@ func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
|
|||||||
Arguments: []commandkit.Argument[restoreInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "Session identifier."}, true, parseString,
|
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 })},
|
func(input *restoreInput, value commandkit.Supplied[string]) { input.Session = value.Value })},
|
||||||
Run: func(invocation commandkit.Invocation, input restoreInput) commandkit.Outcome {
|
Run: func(invocation commandkit.Invocation, input restoreInput) commandkit.Outcome {
|
||||||
|
reporter := invocation.Reporter()
|
||||||
if err := invocation.Context.Err(); err != nil {
|
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)
|
application, err := loadApplicationConfig(invocation.Context, ckconfig.Selection{}, nil, nil)
|
||||||
if err != 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)
|
name, _, err := selectedCampaign(application.Config, input.Campaign)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return commandkit.Failure(err)
|
return commandkit.Failure(err.Error()).WithCause(err)
|
||||||
}
|
}
|
||||||
record, ok := store[input.Session]
|
record, ok := store[input.Session]
|
||||||
if !ok {
|
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 {
|
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)}
|
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."}}
|
||||||
if _, err := fmt.Fprintf(invocation.Streams.Out, "restored %s: %s\n", record.ID, record.Summary); err != nil {
|
|
||||||
return commandkit.Failure(err)
|
|
||||||
}
|
}
|
||||||
|
_ = 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.Success()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return commandkit.New(
|
declarations := []commandkit.Declaration{
|
||||||
commandkit.Identity{Name: "composed-example", Version: "1.0"},
|
|
||||||
configCommand,
|
configCommand,
|
||||||
definitionCommand,
|
definitionCommand,
|
||||||
|
commandkit.Completion("composed-example"),
|
||||||
commandkit.Group("session", "Manage saved sessions.", restore),
|
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 }
|
func parseString(raw string) (string, error) { return raw, nil }
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
@@ -350,6 +352,153 @@ func TestComposedCatalogsAndSmokeUseInjectedClientOnlyForInference(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestComposedCompletionUsesHintsWithoutLoadingEngine(t *testing.T) {
|
||||||
|
var loads, calls, smokeCalls int
|
||||||
|
loader := func(ctx context.Context, selection ckconfig.Selection, input *inspectionInput, cache *runCache) (*promptkit.Engine, error) {
|
||||||
|
loads++
|
||||||
|
return loadPromptEngine(ctx, selection, input, cache)
|
||||||
|
}
|
||||||
|
smoke := func(context.Context, ckprompt.SmokeRequest, *inspectionInput, *runCache) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
|
||||||
|
smokeCalls++
|
||||||
|
return commandkit.Acquired[*promptkit.SmokeTestReport]{}, nil
|
||||||
|
}
|
||||||
|
app, err := newAppWithEngineLoader(&exampleLLMClient{calls: &calls}, loader, smoke)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{[]string{"__complete", "promptkit", "test", "re"}, "reachability\n"},
|
||||||
|
{[]string{"__complete", "smoke", "al"}, "all\n"},
|
||||||
|
{[]string{"__complete", "pk", "show", "prompt", "briefing", "--fmt=y"}, "yaml\n"},
|
||||||
|
{[]string{"__complete", "pk", "show", "prompt", "briefing", "--config=fixtures/"}, ":0\n"},
|
||||||
|
} {
|
||||||
|
var output bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), tc.args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
if outcome.Status != 0 || !strings.Contains(output.String(), tc.want) {
|
||||||
|
t.Fatalf("query %q outcome=%#v output=%q", tc.args, outcome, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loads != 0 || calls != 0 || smokeCalls != 0 {
|
||||||
|
t.Fatalf("completion loaded engine %d times and invoked client %d times, smoke calls=%d", loads, calls, smokeCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
func composedReportValue(t *testing.T, data []byte, category string, fields ...string) *yaml.Node {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var document yaml.Node
|
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 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
|
||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
|
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
|
||||||
gitea.maximumdirect.net/eric/promptkit v0.16.0 h1:g3dhN0QfzQriaVOBmGUZCw2iBM9Mli2AAU4CbMfzcuY=
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,26 @@
|
|||||||
"product": "CommandKit ckprompt",
|
"product": "CommandKit ckprompt",
|
||||||
"module_dir": "integrations/ckprompt",
|
"module_dir": "integrations/ckprompt",
|
||||||
"remote": "origin",
|
"remote": "origin",
|
||||||
"expected_push_url": "https://gitea.maximumdirect.net/eric/CommandKit.git",
|
"expected_push_url": "ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git",
|
||||||
"validation": {
|
"validation": {
|
||||||
"shared_files": [
|
"shared_files": [
|
||||||
"COPYRIGHT",
|
"COPYRIGHT",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"docs/oss/attribution.md"
|
"docs/oss/attribution.md"
|
||||||
]
|
],
|
||||||
|
"go_format": true,
|
||||||
|
"markdown": {},
|
||||||
|
"module_distribution": {
|
||||||
|
"workspace_files": true,
|
||||||
|
"local_replacements": true
|
||||||
|
},
|
||||||
|
"forbidden_files": [".releasekit/gitea-token"]
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"forge": "gitea",
|
||||||
|
"base_url": "https://forge.maximumdirect.net",
|
||||||
|
"owner": "base-layer",
|
||||||
|
"repository": "commandkit",
|
||||||
|
"token_file": ".releasekit/gitea-token"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{Issues: []promptkit.RequirementIssue{issue}}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
||||||
}}))
|
}}))
|
||||||
var report, diagnostics bytes.Buffer
|
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
|
var decoded map[string]any
|
||||||
if err := yaml.Unmarshal(report.Bytes(), &decoded); err != nil {
|
if err := yaml.Unmarshal(report.Bytes(), &decoded); err != nil {
|
||||||
t.Fatalf("decode partial report %q: %v", report.String(), err)
|
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())
|
t.Fatalf("partial inspection outcome=%#v report=%q diagnostics=%q", outcome, report.String(), diagnostics.String())
|
||||||
}
|
}
|
||||||
issues, ok := decoded["issues"].([]any)
|
issues, ok := decoded["issues"].([]any)
|
||||||
@@ -339,8 +339,8 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
|
|||||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, providerErr
|
return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, providerErr
|
||||||
}}))
|
}}))
|
||||||
var report, diagnostics bytes.Buffer
|
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})
|
||||||
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 {
|
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())
|
t.Fatalf("provider failure outcome=%#v report=%q diagnostics=%q", outcome, report.String(), diagnostics.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -351,9 +351,9 @@ func TestRequirementsCommandPreservesDiagnosticsCancellationAndDelivery(t *testi
|
|||||||
cancel()
|
cancel()
|
||||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{Complete: true}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{Complete: true}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
||||||
}}))
|
}}))
|
||||||
var report bytes.Buffer
|
var report, diagnostics bytes.Buffer
|
||||||
outcome := app.Execute(ctx, []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: &report, Err: io.Discard})
|
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) || len(outcome.Diagnostics) == 0 || report.Len() != 0 {
|
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())
|
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
|
return commandkit.Acquired[*promptkit.RequirementInspection]{}, nil
|
||||||
}}))
|
}}))
|
||||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
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)
|
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
|
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})
|
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())
|
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
|
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: &promptkit.RequirementInspection{PromptID: "briefing"}, Diagnostics: []commandkit.Diagnostic{badDiagnostic}}, nil
|
||||||
}}))
|
}}))
|
||||||
var report bytes.Buffer
|
var report bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "show", "briefing"}, commandkit.Streams{Out: &report, Err: io.Discard})
|
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.DeliveryError, diagnosticErr) || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || len(outcome.Diagnostics) == 0 || report.Len() == 0 {
|
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())
|
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;
|
// invocation context. Its complete returned request is passed to PromptKit;
|
||||||
// without a builder, the seed is forwarded unchanged. Reports and errors from
|
// 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
|
// 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
|
// does not install a smoke-test callback. The callback also retains independent
|
||||||
// loading diagnostics with the report and error.
|
// 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](
|
func WithSmokeTesting[I, S any](
|
||||||
suite Suite[I, S],
|
suite Suite[I, S],
|
||||||
load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error),
|
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)
|
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 {
|
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 {
|
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)
|
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
|
return suite
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,15 +28,15 @@ func newSmokeTestCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
|||||||
return &(*input).captured
|
return &(*input).captured
|
||||||
})
|
})
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Config = value
|
(*input).selection.Config = value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).selection.Overlay = value
|
(*input).selection.Overlay = value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Smoke report format: yaml."}, stringDefault("yaml"), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Smoke report format: yaml.", Completion: completionChoices("yaml")}, stringDefault("yaml"), parseDefinitionString,
|
||||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "backend", Usage: "PromptKit backend ID."}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "backend", Usage: "PromptKit backend ID."}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) {
|
||||||
@@ -138,7 +138,7 @@ func newSmokeTestCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
|||||||
},
|
},
|
||||||
Flags: flags,
|
Flags: flags,
|
||||||
Arguments: []commandkit.Argument[*smokeCommandInput[I]]{
|
Arguments: []commandkit.Argument[*smokeCommandInput[I]]{
|
||||||
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "kind", Usage: "Native smoke-test kind."}, true, parseDefinitionString,
|
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "kind", Usage: "Native smoke-test kind.", Completion: smokeKindCompletion()}, true, parseDefinitionString,
|
||||||
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).kind = value.Value }),
|
func(input **smokeCommandInput[I], value commandkit.Supplied[string]) { (*input).kind = value.Value }),
|
||||||
},
|
},
|
||||||
CheckInput: func(input *smokeCommandInput[I]) error {
|
CheckInput: func(input *smokeCommandInput[I]) error {
|
||||||
@@ -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 {
|
func runSmokeTest[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *smokeCommandInput[I]) commandkit.Outcome {
|
||||||
if err := promptKitContextError(invocation.Context); err != nil {
|
if err := promptKitContextError(invocation.Context); err != nil {
|
||||||
return definitionFailure(err, nil)
|
return definitionFailure("PromptKit smoke testing was canceled.", commandkit.Explanation{}, err)
|
||||||
}
|
}
|
||||||
var state S
|
var state S
|
||||||
if suite.NewState != nil {
|
if suite.NewState != nil {
|
||||||
state = suite.NewState()
|
state = suite.NewState()
|
||||||
}
|
}
|
||||||
if err := promptKitContextError(invocation.Context); err != nil {
|
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" {
|
if input.kind == "all" {
|
||||||
return runSmokeAggregate(invocation, suite, input, state)
|
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}
|
request := SmokeRequest{Selection: input.selection, Request: input.request}
|
||||||
acquired, operationErr := suite.SmokeTest(invocation.Context, request, input.selected, state)
|
acquired, operationErr := suite.SmokeTest(invocation.Context, request, input.selected, state)
|
||||||
|
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||||
if acquired.Value == nil && operationErr == nil {
|
if acquired.Value == nil && operationErr == nil {
|
||||||
operationErr = errors.New("PromptKit smoke test returned neither a report nor an error")
|
operationErr = errors.New("PromptKit smoke test returned neither a report nor an error")
|
||||||
}
|
}
|
||||||
@@ -210,31 +211,42 @@ func runSmokeTest[I, S any](invocation commandkit.Invocation, suite Suite[I, S],
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if acquired.Value == nil {
|
if acquired.Value == nil {
|
||||||
return definitionFailure(operationErr, acquired.Diagnostics)
|
return definitionFailure(smokeFailureMessage(input.kind), acquired.Explanation, operationErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
output, encodeErr := encodeSmokeReport(acquired.Value)
|
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||||
if encodeErr != nil {
|
output, err := encodeSmokeReport(acquired.Value)
|
||||||
return smokeDeliveryFailure(operationErr, encodeErr, acquired.Diagnostics, false)
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return writeSmokeReport(invocation, output, acquired.Diagnostics, operationErr)
|
_, err = writer.Write(output)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if operationErr != nil {
|
||||||
|
return definitionFailure(smokeFailureMessage(input.kind), acquired.Explanation, operationErr)
|
||||||
|
}
|
||||||
|
return commandkit.Success()
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *smokeCommandInput[I], state S) commandkit.Outcome {
|
func aggregateSmokeKinds() [4]promptkit.SmokeTestKind {
|
||||||
kinds := [...]promptkit.SmokeTestKind{
|
return [...]promptkit.SmokeTestKind{
|
||||||
promptkit.SmokeTestReachability,
|
promptkit.SmokeTestReachability,
|
||||||
promptkit.SmokeTestAuthentication,
|
promptkit.SmokeTestAuthentication,
|
||||||
promptkit.SmokeTestTextInference,
|
promptkit.SmokeTestTextInference,
|
||||||
promptkit.SmokeTestStructuredOutput,
|
promptkit.SmokeTestStructuredOutput,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *smokeCommandInput[I], state S) commandkit.Outcome {
|
||||||
|
kinds := aggregateSmokeKinds()
|
||||||
results := make([]smokeResult, 0, len(kinds))
|
results := make([]smokeResult, 0, len(kinds))
|
||||||
var diagnostics []commandkit.Diagnostic
|
|
||||||
var operationErrors []error
|
var operationErrors []error
|
||||||
|
var explanations []commandkit.Explanation
|
||||||
|
|
||||||
for _, kind := range kinds {
|
for _, kind := range kinds {
|
||||||
if err := promptKitContextError(invocation.Context); err != nil {
|
if err := promptKitContextError(invocation.Context); err != nil {
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
return definitionFailure(err, diagnostics)
|
return definitionFailure("PromptKit aggregate smoke testing was canceled.", commandkit.Explanation{}, err)
|
||||||
}
|
}
|
||||||
operationErrors = append(operationErrors, err)
|
operationErrors = append(operationErrors, err)
|
||||||
break
|
break
|
||||||
@@ -253,10 +265,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})
|
results = append(results, smokeResult{Kind: kind, Report: acquired.Value, Err: operationErr})
|
||||||
diagnostics = append(diagnostics, acquired.Diagnostics...)
|
|
||||||
if operationErr != nil {
|
if operationErr != nil {
|
||||||
operationErrors = append(operationErrors, operationErr)
|
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 {
|
if err := promptKitContextError(invocation.Context); err != nil {
|
||||||
break
|
break
|
||||||
@@ -264,13 +279,57 @@ func runSmokeAggregate[I, S any](invocation commandkit.Invocation, suite Suite[I
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(results) == 0 {
|
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)
|
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||||
if encodeErr != nil {
|
output, err := encodeSmokeResults(results)
|
||||||
return smokeDeliveryFailure(errors.Join(operationErrors...), encodeErr, diagnostics, false)
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return writeSmokeReport(invocation, output, diagnostics, errors.Join(operationErrors...))
|
_, 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 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 {
|
func smokeRequestForKind(seed promptkit.SmokeTestRequest, kind promptkit.SmokeTestKind) promptkit.SmokeTestRequest {
|
||||||
@@ -312,30 +371,3 @@ func copyString(value *string) *string {
|
|||||||
copy := *value
|
copy := *value
|
||||||
return ©
|
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) {
|
func TestSmokeTestCommandDeliversReportWithOperationError(t *testing.T) {
|
||||||
sentinel := errors.New("native smoke operation failed")
|
sentinel := errors.New("native smoke operation failed")
|
||||||
|
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent smoke warning")
|
||||||
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
|
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
|
||||||
SmokeTest: func(context.Context, SmokeRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
|
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
|
var output, diagnosticOutput bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
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 {
|
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, sentinel) || output.Len() == 0 {
|
||||||
t.Fatalf("report-plus-error outcome = (%#v, %q)", outcome, output.String())
|
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{}]{
|
app = newPromptTestApp(t, Command(Suite[struct{}, struct{}]{
|
||||||
SmokeTest: func(context.Context, SmokeRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
|
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})
|
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)
|
t.Fatalf("short report delivery = %#v", outcome)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,10 +105,9 @@ func TestSmokeTestEncodingAndWriterFailuresRetainOutcomeFields(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
var diagnosticOutput bytes.Buffer
|
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")
|
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.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()) {
|
||||||
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 {
|
|
||||||
t.Fatalf("report delivery = %#v, diagnostics=%q", outcome, diagnosticOutput.String())
|
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")
|
diagnosticErr := errors.New("smoke diagnostic writer failed")
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: &output, Err: partialReportWriter{err: diagnosticErr}})
|
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.DeliveryError, diagnosticErr) || outcome.ReportIncomplete || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || output.Len() == 0 {
|
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())
|
t.Fatalf("diagnostic delivery = (%#v, %q)", outcome, output.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +129,7 @@ func TestSmokeTestEncodingAndWriterFailuresRetainOutcomeFields(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
output.Reset()
|
output.Reset()
|
||||||
outcome = encodeApp.Execute(context.Background(), []string{"promptkit", "test", "authentication"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
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())
|
t.Fatalf("encoding failure = (%#v, %q)", outcome, output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,15 +150,24 @@ func TestSmokeAggregatePreservesMultipleTypedCauses(t *testing.T) {
|
|||||||
case promptkit.SmokeTestAuthentication:
|
case promptkit.SmokeTestAuthentication:
|
||||||
err = secondErr
|
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
|
var output, diagnosticOutput bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "all"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||||
var retained *smokeTypedError
|
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 {
|
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)
|
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 {
|
var document struct {
|
||||||
Tests []struct {
|
Tests []struct {
|
||||||
Kind string `yaml:"kind"`
|
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"}}
|
request := ValidationRequest{Request: promptkit.RunRequest{PromptID: "empty", ProfileID: "default"}}
|
||||||
nilEngineSuite := WithRequestValidation(Suite[struct{}, struct{}]{}, func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
|
nilEngineSuite := WithRequestValidation(Suite[struct{}, struct{}]{}, func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -232,8 +232,8 @@ func TestWithRequestValidationRetainsNilEngineAndLoadDiagnostics(t *testing.T) {
|
|||||||
}, nil)
|
}, nil)
|
||||||
acquired, err = loadFailure.ValidateRequest(context.Background(), request, struct{}{}, struct{}{})
|
acquired, err = loadFailure.ValidateRequest(context.Background(), request, struct{}{}, struct{}{})
|
||||||
var retained *configkit.LoadError
|
var retained *configkit.LoadError
|
||||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
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 diagnostics", acquired, err)
|
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)
|
}, nil)
|
||||||
acquired, err := suite.ValidateRequest(ctx, request, 0, 0)
|
acquired, err := suite.ValidateRequest(ctx, request, 0, 0)
|
||||||
var retained *configkit.LoadError
|
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)
|
t.Fatalf("cancelled load = (%#v, %v), loader calls=%d", acquired, err, loaderCalls)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ckprompt
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
@@ -28,11 +29,11 @@ func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
|||||||
return &(*input).captured
|
return &(*input).captured
|
||||||
})
|
})
|
||||||
flags = append(flags,
|
flags = append(flags,
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
|
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).request.Selection.Config = value
|
(*input).request.Selection.Config = value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseDefinitionString,
|
||||||
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
|
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).request.Selection.Overlay = value
|
(*input).request.Selection.Overlay = value
|
||||||
}),
|
}),
|
||||||
@@ -44,7 +45,7 @@ func newValidationCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
|||||||
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
|
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).request.Request.SessionID = value.Value
|
(*input).request.Request.SessionID = value.Value
|
||||||
}),
|
}),
|
||||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Confirmation format: yaml."}, stringDefault("yaml"), parseDefinitionString,
|
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Confirmation format: yaml.", Completion: completionChoices("yaml")}, stringDefault("yaml"), parseDefinitionString,
|
||||||
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
|
func(input **validationCommandInput[I], value commandkit.Supplied[string]) {
|
||||||
(*input).format = value.Value
|
(*input).format = value.Value
|
||||||
}),
|
}),
|
||||||
@@ -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 {
|
func runRequestValidation[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *validationCommandInput[I]) commandkit.Outcome {
|
||||||
if err := promptKitContextError(invocation.Context); err != nil {
|
if err := promptKitContextError(invocation.Context); err != nil {
|
||||||
return definitionFailure(err, nil)
|
return definitionFailure("PromptKit request validation was canceled.", commandkit.Explanation{}, err)
|
||||||
}
|
}
|
||||||
var state S
|
var state S
|
||||||
if suite.NewState != nil {
|
if suite.NewState != nil {
|
||||||
state = suite.NewState()
|
state = suite.NewState()
|
||||||
}
|
}
|
||||||
if err := promptKitContextError(invocation.Context); err != nil {
|
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)
|
acquired, err := suite.ValidateRequest(invocation.Context, input.request, input.selected, state)
|
||||||
|
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||||
if err != nil {
|
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 {
|
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})
|
return renderPreparedPromptReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||||
if err != nil {
|
return encodePromptYAMLBytes(requestValidationReport{PromptID: input.promptID, Valid: true})
|
||||||
return definitionFailure(err, acquired.Diagnostics)
|
})
|
||||||
}
|
|
||||||
return writePromptReport(invocation, output, acquired.Diagnostics)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func TestValidationCommandIsAvailableIndependentlyAndOwnsItsFamily(t *testing.T)
|
|||||||
}}))
|
}}))
|
||||||
var help bytes.Buffer
|
var help bytes.Buffer
|
||||||
outcome := validationOnly.Execute(context.Background(), []string{"help", "promptkit", "requirements"}, commandkit.Streams{Out: &help, Err: io.Discard})
|
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())
|
t.Fatalf("validation-only availability = (%#v, %q)", outcome, help.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,41 +359,32 @@ func TestValidationCommandUsesNativeOutcomesAndMinimalConfirmation(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidationCommandRetainsProviderDiagnosticsAndClassifiesFailures(t *testing.T) {
|
func TestValidationCommandRetainsProviderDiagnosticsAndClassifiesFailures(t *testing.T) {
|
||||||
warning := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "validation warning")
|
||||||
_, 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
|
|
||||||
}
|
|
||||||
var diagnostics bytes.Buffer
|
var diagnostics bytes.Buffer
|
||||||
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
|
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
|
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})
|
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.DiagnosticDelivery != commandkit.DeliveryComplete || !withWarning(outcome.Diagnostics) || diagnostics.String() != "validation warning\n" {
|
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())
|
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")
|
providerErr := errors.New("validation provider failed")
|
||||||
failedApp := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
|
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
|
return commandkit.Acquired[struct{}]{Diagnostics: []commandkit.Diagnostic{warning}}, providerErr
|
||||||
}}))
|
}}))
|
||||||
diagnostics.Reset()
|
diagnostics.Reset()
|
||||||
outcome = failedApp.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: &diagnostics})
|
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) || !withWarning(outcome.Diagnostics) || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
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())
|
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 {
|
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 diagnostics were lost or duplicated: stderr=%q", diagnostics.String())
|
t.Fatalf("provider warning or terminal explanation was incorrect: stderr=%q", diagnostics.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
builderErr := errors.New("request builder failed")
|
builderErr := errors.New("request builder failed")
|
||||||
@@ -418,16 +409,13 @@ func TestValidationCommandRetainsProviderDiagnosticsAndClassifiesFailures(t *tes
|
|||||||
}, nil)))
|
}, nil)))
|
||||||
outcome = loadApp.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
outcome = loadApp.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||||
var retained *configkit.LoadError
|
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)
|
t.Fatalf("loader failure = %#v, want retained ConfigKit error and diagnostics", outcome)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidationCommandCancellationPreventsAcquisitionAndReport(t *testing.T) {
|
func TestValidationCommandCancellationPreventsAcquisitionAndReport(t *testing.T) {
|
||||||
warning := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "validation warning")
|
||||||
_, err := io.WriteString(writer, "validation warning\n")
|
|
||||||
return err
|
|
||||||
}}
|
|
||||||
calls := 0
|
calls := 0
|
||||||
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
|
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ValidateRequest: func(context.Context, ValidationRequest, struct{}, struct{}) (commandkit.Acquired[struct{}], error) {
|
||||||
calls++
|
calls++
|
||||||
@@ -448,8 +436,9 @@ func TestValidationCommandCancellationPreventsAcquisitionAndReport(t *testing.T)
|
|||||||
return commandkit.Acquired[struct{}]{Diagnostics: []commandkit.Diagnostic{warning}}, nil
|
return commandkit.Acquired[struct{}]{Diagnostics: []commandkit.Diagnostic{warning}}, nil
|
||||||
}}))
|
}}))
|
||||||
output.Reset()
|
output.Reset()
|
||||||
outcome = canceledApp.Execute(ctx, []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
var diagnostics bytes.Buffer
|
||||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !withValidationWarning(outcome.Diagnostics) || output.Len() != 0 {
|
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())
|
t.Fatalf("post-acquisition cancellation = %#v, output=%q", outcome, output.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,8 +448,9 @@ func TestValidationCommandCancellationPreventsAcquisitionAndReport(t *testing.T)
|
|||||||
cancel()
|
cancel()
|
||||||
return commandkit.Acquired[struct{}]{Diagnostics: []commandkit.Diagnostic{warning}}, providerErr
|
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})
|
var errorDiagnostics bytes.Buffer
|
||||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, providerErr) || !withValidationWarning(outcome.Diagnostics) {
|
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)
|
t.Fatalf("provider error after cancellation = %#v", outcome)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,28 +474,16 @@ func TestValidationCommandPreservesReportAndDiagnosticDeliveryFailures(t *testin
|
|||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
var diagnosticOutput bytes.Buffer
|
var diagnosticOutput bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing"}, commandkit.Streams{Out: test.writer, Err: &diagnosticOutput})
|
outcome := app.Execute(context.Background(), []string{"promptkit", "requirements", "validate", "briefing", "--verbose"}, 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") {
|
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())
|
t.Fatalf("report delivery = %#v, diagnostics=%q", outcome, diagnosticOutput.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
diagnosticErr := errors.New("validation diagnostic writer failed")
|
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}})
|
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 || outcome.ReportIncomplete || !errors.Is(outcome.DeliveryError, diagnosticErr) || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete {
|
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Diagnostics.WriteError, diagnosticErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
|
||||||
t.Fatalf("diagnostic delivery = %#v", outcome)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user