Compare commits
162 Commits
v0.2.0
...
integratio
| Author | SHA1 | Date | |
|---|---|---|---|
| bc1d1d5844 | |||
| 260256d722 | |||
| 5a79b56e3c | |||
| 5ccdcb91c9 | |||
| 42176a7ac4 | |||
| 72099958d1 | |||
| 2904eadf4f | |||
| 1c778d7ad3 | |||
| 16584084b1 | |||
| ce8ab3be38 | |||
| 7a9e3a1f7c | |||
| 60841227b7 | |||
| 3699c6c5e6 | |||
| 3f1ed2f107 | |||
| 4a8f73f289 | |||
| 92a327db97 | |||
| f0121ad466 | |||
| 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
|
||||||
|
|||||||
25
README.md
25
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. |
|
| `go.maximumdirect.net/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. |
|
| `go.maximumdirect.net/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 published ckconfig and
|
||||||
`commandkit.New` or `App.Register` to install it. The integrations depend on the
|
ckprompt v0.2.0 modules use core v0.3.0. The local
|
||||||
core and their selected upstream libraries; the core does not depend on either
|
ckconfig v0.3.0 candidate uses ConfigKit v1.1.0 and does not compose with
|
||||||
integration.
|
ckprompt v0.2.0; the published v0.2.0 pair remains matched. 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)
|
||||||
|
|
||||||
@@ -38,7 +43,7 @@ integration.
|
|||||||
- [Maintainer release procedure](docs/release.md)
|
- [Maintainer release procedure](docs/release.md)
|
||||||
- [Architecture policy](docs/policy/architecture.md)
|
- [Architecture policy](docs/policy/architecture.md)
|
||||||
- [Policy provenance](docs/policy/meta.md)
|
- [Policy provenance](docs/policy/meta.md)
|
||||||
- [Deferred capabilities and proposals](docs/roadmap/deferred.md)
|
- [Future work and proposals](docs/roadmap/future.md)
|
||||||
|
|
||||||
CommandKit is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
CommandKit is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
||||||
|
|
||||||
|
|||||||
45
app.go
45
app.go
@@ -3,38 +3,59 @@ 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
|
||||||
// independent registry; command declarations may be reused across Apps.
|
// independent registry; command declarations may be reused across Apps.
|
||||||
type App struct {
|
type App struct {
|
||||||
identity Identity
|
identity Identity
|
||||||
commands map[string]*commandNode
|
commands map[string]*commandNode
|
||||||
snapshot *routingSnapshot
|
routingRules []RoutingRule
|
||||||
|
aliases *alias.Resolver
|
||||||
|
flagAliases map[string]resolvedFlagAliases
|
||||||
|
discoveryFlags map[string]resolvedFlagAliases
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
96
builtins.go
96
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
|
if identity.Version == "" {
|
||||||
|
identity.Version = "dev"
|
||||||
|
}
|
||||||
switch format {
|
switch format {
|
||||||
case "text":
|
case "text":
|
||||||
name := identity.Name
|
line := identity.Name + " " + identity.Version
|
||||||
if identity.Version == "" {
|
|
||||||
identity.Version = "dev"
|
|
||||||
}
|
|
||||||
line := 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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
221
command.go
221
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 {
|
||||||
@@ -151,13 +207,14 @@ func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag {
|
|||||||
for index, flag := range c.spec.Flags {
|
for index, flag := range c.spec.Flags {
|
||||||
binding := flag.binding
|
binding := flag.binding
|
||||||
flags[index] = commandmeta.Flag{
|
flags[index] = commandmeta.Flag{
|
||||||
Name: binding.meta.Name,
|
Name: binding.meta.Name,
|
||||||
Short: binding.meta.Short,
|
Short: binding.meta.Short,
|
||||||
Usage: binding.meta.Usage,
|
Usage: binding.meta.Usage,
|
||||||
Default: binding.defaultText,
|
Default: binding.defaultText,
|
||||||
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
|
||||||
@@ -168,17 +225,18 @@ func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument {
|
|||||||
for index, argument := range c.spec.Arguments {
|
for index, argument := range c.spec.Arguments {
|
||||||
binding := argument.binding
|
binding := argument.binding
|
||||||
arguments[index] = commandmeta.Argument{
|
arguments[index] = commandmeta.Argument{
|
||||||
Name: binding.meta.Name,
|
Name: binding.meta.Name,
|
||||||
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.
|
||||||
243
docs/cli.md
243
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
|
||||||
|
|
||||||
@@ -171,14 +363,14 @@ sources:
|
|||||||
- id: source-1
|
- id: source-1
|
||||||
label: "application config"
|
label: "application config"
|
||||||
path: /etc/example/config.yml
|
path: /etc/example/config.yml
|
||||||
layer: base
|
layer: main
|
||||||
kind: file
|
kind: file
|
||||||
underlay_index: 0
|
underlay_index: 0
|
||||||
imported_from: null
|
imported_from: null
|
||||||
destination: /server
|
destination: /server
|
||||||
placement: mapping
|
placement: mapping
|
||||||
destination_unresolved: false
|
destination_is_fallback: false
|
||||||
version_check:
|
version_evidence:
|
||||||
path: /version
|
path: /version
|
||||||
version:
|
version:
|
||||||
present: true
|
present: true
|
||||||
@@ -189,22 +381,28 @@ sources:
|
|||||||
line: 2
|
line: 2
|
||||||
column: 1
|
column: 1
|
||||||
redacted: false
|
redacted: false
|
||||||
|
empty_import_selections: []
|
||||||
```
|
```
|
||||||
|
|
||||||
Each source includes `id`, `label`, `path`, `layer`, `kind`, `underlay_index`,
|
Each source includes `id`, `label`, `path`, `layer`, `kind`, `underlay_index`,
|
||||||
and `imported_from`, plus the native destination pointer, placement, unresolved
|
and `imported_from`, plus the native destination pointer, placement,
|
||||||
destination flag, and nullable `version_check`. Placement is `mapping` or
|
`destination_is_fallback`, nullable `version_evidence`, and
|
||||||
`sequence_item`. The import location is null when absent; otherwise it has
|
`empty_import_selections`. Placement is `mapping` or `sequence_item`. The
|
||||||
|
import location is null when absent; otherwise it has
|
||||||
`source_id`, `line`, and `column`. Version evidence preserves `path`, `present`,
|
`source_id`, `line`, and `column`. Version evidence preserves `path`, `present`,
|
||||||
`null`, `text`, optional source `location`, and `redacted` as returned by
|
`null`, `text`, optional source `location`, and `redacted` as returned by
|
||||||
ConfigKit. Owning values and source order are preserved, including empty fields,
|
ConfigKit. Owning values and source order are preserved, including empty fields,
|
||||||
zero coordinates, false and null observations, and synthetic sources without
|
zero coordinates, false and null observations, and synthetic sources without
|
||||||
paths. An absent version check is null. An empty inventory is `sources: []`.
|
paths. Absent version evidence is null. Each empty-import record has a source-local
|
||||||
|
`path`, a `location` mapping (`source_id`, `line`, `column`), and a `selector`;
|
||||||
|
records preserve their returned order. No records are `empty_import_selections: []`.
|
||||||
|
An empty inventory is `sources: []`.
|
||||||
Text output begins with the scope heading and prints the same source fields,
|
Text output begins with the scope heading and prints the same source fields,
|
||||||
quoting labels and paths. These fields project the selected ConfigKit source
|
quoting labels and paths. These fields project the selected ConfigKit source
|
||||||
records; CommandKit does not reload files or rerun version checks. See the
|
records; CommandKit does not reload files or rerun version checks. See the
|
||||||
[ConfigKit v0.8.0 inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/inspection.go)
|
[ConfigKit v1.1.0 source records](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/records.go)
|
||||||
for native evidence definitions.
|
for native evidence definitions. Ckprompt v0.2.0 uses
|
||||||
|
[ConfigKit v0.8.0](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/inspection.go).
|
||||||
|
|
||||||
### Explain a configuration path
|
### Explain a configuration path
|
||||||
|
|
||||||
@@ -235,7 +433,7 @@ effective:
|
|||||||
redacted: false
|
redacted: false
|
||||||
yaml: "localhost:8080\n"
|
yaml: "localhost:8080\n"
|
||||||
origin:
|
origin:
|
||||||
layer: base
|
layer: main
|
||||||
locations:
|
locations:
|
||||||
- source_id: source-1
|
- source_id: source-1
|
||||||
line: 4
|
line: 4
|
||||||
@@ -322,8 +520,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 +738,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 +934,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 +948,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,17 +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. |
|
| `go.maximumdirect.net/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. |
|
| `go.maximumdirect.net/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
|
||||||
|
|
||||||
|
The published matched releases are core v0.3.0, ckconfig v0.2.0, and ckprompt v0.2.0.
|
||||||
|
The local ckconfig v0.3.0 candidate uses ConfigKit v1.1.0 and does not compose
|
||||||
|
with ckprompt v0.2.0, which retains ckconfig v0.2.0 and ConfigKit v0.8.0.
|
||||||
|
Update application imports and dependency versions together; old and new core
|
||||||
|
paths have distinct Go type identities. Install core with
|
||||||
|
`go get go.maximumdirect.net/commandkit@v0.3.0` and either integration with
|
||||||
|
`go get go.maximumdirect.net/ckconfig@v0.2.0` or
|
||||||
|
`go get go.maximumdirect.net/ckprompt@v0.2.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
|
||||||
@@ -21,11 +32,13 @@ 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
|
Core and ckprompt declare a consumer minimum of Go 1.26.0; the ckconfig
|
||||||
selects ReleaseKit v0.4.2 as a Go tool, which makes that tool's module graph part
|
v0.3.0 candidate requires Go 1.27.0. The root module also
|
||||||
of the root consumer module requirements even though production packages do not
|
selects ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) as a Go tool, which
|
||||||
import ReleaseKit. The [release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
|
makes that tool's module graph part of the root consumer module requirements
|
||||||
defines the support assumption. Maintainers use Go 1.26.8 for validation and
|
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.27.0 for validation and
|
||||||
publication; see the [release procedure](../release.md#release-toolchain).
|
publication; see the [release procedure](../release.md#release-toolchain).
|
||||||
|
|
||||||
## Core commands
|
## Core commands
|
||||||
@@ -41,7 +54,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 +75,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 +98,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,16 +341,33 @@ 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
|
||||||
formats, profile resolution, and inspection details. See the
|
formats, profile resolution, and inspection details. See the
|
||||||
[ConfigKit v0.8.0 inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/inspection.go),
|
[ConfigKit v0.8.0 inspection API for ckprompt v0.2.0](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/inspection.go),
|
||||||
[PromptKit engine methods](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go),
|
[PromptKit engine methods](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go),
|
||||||
[configuration inspection](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/configuration_inspection.go),
|
[configuration inspection](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/configuration_inspection.go),
|
||||||
[smoke diagnostics](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/smoke_diagnostics.go),
|
[smoke diagnostics](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/smoke_diagnostics.go),
|
||||||
and [PromptKit v0.16.0 consumer guidance](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md).
|
and [PromptKit v0.16.0 consumer guidance](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md).
|
||||||
|
|
||||||
|
## ConfigKit v1.1 migration
|
||||||
|
|
||||||
|
The ckconfig v0.3.0 candidate uses `go.maximumdirect.net/configkit v1.1.0`
|
||||||
|
and Go 1.27.0. Consumers must migrate ConfigKit imports and values passed to
|
||||||
|
ckconfig: the old and new package paths have distinct type identities. Source
|
||||||
|
reports rename `destination_unresolved` to `destination_is_fallback` and
|
||||||
|
`version_check` to `version_evidence`, and add `empty_import_selections`.
|
||||||
|
|
||||||
|
The upstream [v0.9 migration notes](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/docs/releases/v0.9.0.md),
|
||||||
|
[v0.10 notes](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/docs/releases/v0.10.0.md),
|
||||||
|
[v1.0 migration notes](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/docs/releases/v1.0.0.md),
|
||||||
|
and [v1.1 notes](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/docs/releases/v1.1.0.md)
|
||||||
|
cover source-root `configkit` controls becoming `config`, import requiredness
|
||||||
|
and defaults, collection registration and operations, removed APIs, and CLI
|
||||||
|
assignment order.
|
||||||
|
|||||||
@@ -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 unfinished work and
|
||||||
|
capability proposals in [future work](roadmap/future.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,15 +54,17 @@ 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, 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) |
|
||||||
| Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0003](adr/0003-public-facade.md) as scoped by [ADR 0010](adr/0010-optional-integration-modules.md), and the architecture policy |
|
| Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0003](adr/0003-public-facade.md) as scoped by [ADR 0010](adr/0010-optional-integration-modules.md), and the architecture policy |
|
||||||
| Module ownership and public contracts | [Consumer guide](consumers/guide.md), [internal overview](internal/overview.md), [core GoDoc](../doc.go), [ckconfig GoDoc](../integrations/ckconfig/doc.go), [ckprompt GoDoc](../integrations/ckprompt/doc.go), and applicable ADRs |
|
| Module ownership and public contracts | [Consumer guide](consumers/guide.md), [internal overview](internal/overview.md), [core GoDoc](../doc.go), [ckconfig GoDoc](../integrations/ckconfig/doc.go), [ckprompt GoDoc](../integrations/ckprompt/doc.go), and applicable ADRs |
|
||||||
| Deferred capabilities and proposals | [Deferred work](roadmap/deferred.md) |
|
| Unfinished work and capability proposals | [Future work](roadmap/future.md) |
|
||||||
| Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history |
|
| Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history |
|
||||||
| Release tooling | [Maintainer release procedure](release.md) |
|
| Release tooling | [Maintainer release procedure](release.md) |
|
||||||
| Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [deferred efficiency work](roadmap/deferred.md#possible-further-efficiency-work) |
|
| Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [possible further efficiency work](roadmap/future.md#possible-further-efficiency-work) |
|
||||||
| Licensing or incorporated material | [Project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution](oss/attribution.md) |
|
| Licensing or incorporated material | [Project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution](oss/attribution.md) |
|
||||||
| Policy provenance | [Policy provenance](policy/meta.md) |
|
| Policy provenance | [Policy provenance](policy/meta.md) |
|
||||||
|
|
||||||
@@ -63,10 +72,12 @@ 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 [ConfigKit adoption, reporting, release-tooling, and remediation records](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap),
|
||||||
|
[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
|
[future work record](roadmap/future.md) preserves unfinished work and unselected
|
||||||
and unselected proposals separately from current contracts.
|
proposals separately from current contracts.
|
||||||
|
|
||||||
## Documentation validation
|
## Documentation validation
|
||||||
|
|
||||||
@@ -89,39 +100,24 @@ 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. Preserve the ignored `go.work`
|
||||||
local workspace for all three modules; it is ignored and must not be committed.
|
and `go.work.sum`; use `GOWORK=off GOTOOLCHAIN=local` for dependency preparation,
|
||||||
If a local `go.work` already exists, preserve it and add these module directories:
|
module suites, and examples during the ckconfig migration. Local ckprompt uses
|
||||||
|
the old ConfigKit identity and cannot compose with migrated local ckconfig. Its
|
||||||
|
independent suite and composed example use published ckconfig v0.2.0; restore
|
||||||
|
full local workspace guidance after the coordinated ckprompt migration.
|
||||||
|
Run `go test ./...` separately from each module directory.
|
||||||
|
|
||||||
```sh
|
|
||||||
if [ -f go.work ]; then
|
|
||||||
go work use . ./integrations/ckconfig ./integrations/ckprompt
|
|
||||||
else
|
|
||||||
go work init . ./integrations/ckconfig ./integrations/ckprompt
|
|
||||||
fi
|
|
||||||
go test ./...
|
|
||||||
(cd integrations/ckconfig && go test ./...)
|
|
||||||
(cd integrations/ckprompt && go test ./...)
|
|
||||||
```
|
|
||||||
|
|
||||||
The workspace checks package behavior. Workspace-off checks with each module's
|
|
||||||
own dependency graph are separate evidence. For unpublished CommandKit dependency
|
|
||||||
versions, the [completed integration validation record](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap/implementation.md#stage-12-validate-the-complete-end-state-and-finish)
|
|
||||||
describes the disposable module-file procedure used during implementation.
|
|
||||||
Format changed Go files with `gofmt`. The
|
Format changed Go files with `gofmt`. The
|
||||||
[maintainer release procedure](release.md) owns candidate preparation and
|
[maintainer release procedure](release.md) owns candidate preparation and
|
||||||
publication commands.
|
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.
|
||||||
@@ -135,26 +131,40 @@ external services. The PromptKit smoke examples use local fixtures and the
|
|||||||
example's configured client:
|
example's configured client:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./examples/core workspace greet --count=0 --tag=first --tag second
|
GOWORK=off GOTOOLCHAIN=local go run ./examples/core workspace greet --count=0 --tag=first --tag second
|
||||||
(cd integrations/ckconfig/examples/simple && go run . config show)
|
(cd integrations/ckconfig/examples/simple && GOWORK=off GOTOOLCHAIN=local go run . config show)
|
||||||
(cd integrations/ckconfig/examples/simple && go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml)
|
(cd integrations/ckconfig/examples/simple && GOWORK=off GOTOOLCHAIN=local go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . config validate --scope all --campaign spring --limit=0)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . config validate --scope all --campaign spring --limit=0)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list prompts --config fixtures/before.yml --campaign spring)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit list prompts --config fixtures/before.yml --campaign spring)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list backends --namespace team)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit list backends --namespace team)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list namespaces --kind prompt --kind profile)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit list namespaces --kind prompt --kind profile)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list collections)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit list collections)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit show prompt briefing --campaign spring --format yaml)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit show prompt briefing --campaign spring --format yaml)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements show briefing --campaign spring)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit requirements show briefing --campaign spring)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt --var mode=full --input notes=fixtures/notes.txt --format yaml)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt --var mode=full --input notes=fixtures/notes.txt --format yaml)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit test reachability --campaign spring --prompt briefing --profile spring)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit test reachability --campaign spring --prompt briefing --profile spring)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . promptkit test all --campaign spring --prompt briefing --profile spring)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit test all --campaign spring --prompt briefing --profile spring)
|
||||||
(cd integrations/ckprompt/examples/composed && go run . session restore session-42 --campaign spring)
|
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . session restore session-42 --campaign spring)
|
||||||
```
|
```
|
||||||
|
|
||||||
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, ckconfig, and ckprompt modules declare
|
||||||
|
`go.maximumdirect.net/commandkit`, `go.maximumdirect.net/ckconfig`, and
|
||||||
|
`go.maximumdirect.net/ckprompt`, respectively.
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ invariant, not from the convenience of the selected implementation. Explain the
|
|||||||
specific failure it prevents and why a less restrictive implementation cannot
|
specific failure it prevents and why a less restrictive implementation cannot
|
||||||
satisfy the contract.
|
satisfy the contract.
|
||||||
|
|
||||||
The [deferred-work record](../roadmap/deferred.md) distinguishes future proposals
|
The [future work record](../roadmap/future.md) distinguishes future proposals
|
||||||
from accepted contracts. Do not approve proposed commands, grammar, statuses, or
|
from accepted contracts. Do not approve proposed commands, grammar, statuses, or
|
||||||
illustrative API declarations
|
illustrative API declarations
|
||||||
merely by repeating them in implementation or tests. Cobra/pflag defaults do not
|
merely by repeating them in implementation or tests. Cobra/pflag defaults do not
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -270,7 +294,7 @@ recommended for application-owned commands; their names and domain behavior
|
|||||||
remain application choices.
|
remain application choices.
|
||||||
|
|
||||||
Accepted ADRs and current CLI/API owners define selected contracts; the
|
Accepted ADRs and current CLI/API owners define selected contracts; the
|
||||||
[deferred-work record](../roadmap/deferred.md) preserves remaining proposals.
|
[future work record](../roadmap/future.md) preserves remaining proposals.
|
||||||
Backend defaults and incidental consumer behavior do not establish new contracts.
|
Backend defaults and incidental consumer behavior do not establish new contracts.
|
||||||
A replacement backend must preserve published CLI behavior or follow deliberate compatibility
|
A replacement backend must preserve published CLI behavior or follow deliberate compatibility
|
||||||
changes under the release policy.
|
changes under the release policy.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
145
docs/release.md
145
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,37 +16,40 @@ 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.27.0**. Core and ckprompt declare a
|
||||||
minimum of **Go 1.26.0**. Maintainers use Go 1.26.8 for dependency preparation,
|
consumer minimum of **Go 1.26.0**; ckconfig requires **Go 1.27.0**. Maintainers
|
||||||
candidate validation, and the ReleaseKit bootstrap. The
|
use Go 1.27.0 for dependency preparation, 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.
|
||||||
|
|
||||||
Select Go 1.26.8 on `PATH`; the launcher uses `GOTOOLCHAIN=local`. If needed,
|
Select Go 1.27.0 on `PATH`; the launcher uses `GOTOOLCHAIN=local`. If needed,
|
||||||
install it with the standard Go tools:
|
install it with the standard Go tools:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.26.8@latest
|
GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.27.0@latest
|
||||||
go1.26.8 download
|
go1.27.0 download
|
||||||
export PATH="$(go1.26.8 env GOROOT)/bin:$PATH"
|
export PATH="$(go1.27.0 env GOROOT)/bin:$PATH"
|
||||||
hash -r
|
hash -r
|
||||||
GOWORK=off GOTOOLCHAIN=local go version
|
GOWORK=off GOTOOLCHAIN=local go version
|
||||||
```
|
```
|
||||||
@@ -66,14 +70,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 +88,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 +107,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)
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Deferred work
|
|
||||||
|
|
||||||
Status: retained future work and unselected proposals; no implementation schedule.
|
|
||||||
|
|
||||||
## Purpose and authority
|
|
||||||
|
|
||||||
Completed initial delivery, performance, ReleaseKit upgrade, and remediation
|
|
||||||
records have been retired. This document preserves remaining deferred
|
|
||||||
capabilities and unselected ideas under the
|
|
||||||
[release policy](../policy/release.md#release-documentation-and-preparation).
|
|
||||||
The original records remain available in
|
|
||||||
[Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap).
|
|
||||||
|
|
||||||
Accepted [ADRs](../adr/) and the [architecture policy](../policy/architecture.md)
|
|
||||||
govern any future implementation. The [CLI reference](../cli.md), public GoDoc,
|
|
||||||
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
|
|
||||||
an idea here does not select its syntax, API, priority, or delivery date.
|
|
||||||
|
|
||||||
## Deferred capability targets
|
|
||||||
|
|
||||||
### Comparison extensions
|
|
||||||
|
|
||||||
Independent per-side domain selectors and an optional assertion mode remain
|
|
||||||
unselected extensions to the delivered ConfigKit comparison. Consider them only
|
|
||||||
for a concrete consumer need and a deliberate API/behavior decision under the
|
|
||||||
relevant ADRs.
|
|
||||||
|
|
||||||
## Other deferred proposals
|
|
||||||
|
|
||||||
These capabilities remain outside the delivered scope and require a concrete
|
|
||||||
operator or consumer need and an appropriate ownership/behavior decision:
|
|
||||||
|
|
||||||
- JSON reports, shell completion, and output-file management or reloadable export.
|
|
||||||
- Configuration mutation, generic value assignment, and exhaustive field-origin
|
|
||||||
inventories.
|
|
||||||
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
|
||||||
live provider probes.
|
|
||||||
- Additional component identity registration and domain artifact catalogs.
|
|
||||||
- Additional library families, including possible FeedKit integration; families
|
|
||||||
can define suitable verbs without requiring comparison or configuration scopes.
|
|
||||||
- Backend selection or replacement, a plugin framework, general dependency
|
|
||||||
injection, and concurrent-dispatch guarantees.
|
|
||||||
|
|
||||||
Execution, rendering, serving, and session operations remain application-owned.
|
|
||||||
Shared vocabulary is guidance under [ADR 0006](../adr/0006-command-grammar.md).
|
|
||||||
Backend replacement remains possible behind the accepted facade, subject to the
|
|
||||||
published API/CLI contracts and deliberate compatibility decisions.
|
|
||||||
|
|
||||||
## Possible further efficiency work
|
|
||||||
|
|
||||||
Selected-scope memoization and selector-membership indexes were deferred pending
|
|
||||||
measurements that justify their bookkeeping. Mutable backend reuse across
|
|
||||||
invocations, segmented aggregate YAML delivery, provider caching, consumer-loader
|
|
||||||
redesign, and a replacement backend were outside the completed performance
|
|
||||||
target. Reconsider them only with concrete motivation and review of ownership,
|
|
||||||
invocation state, native reports, and the affected contracts. Retained measurement
|
|
||||||
records are historical evidence, not performance guarantees or release gates.
|
|
||||||
|
|
||||||
## Consumer adoption
|
|
||||||
|
|
||||||
Consumer migrations remain separate from library delivery. Applications can
|
|
||||||
replace duplicated standard handlers and routing, then update their CLI docs
|
|
||||||
and subprocess consumers for intentional changes while retaining domain commands.
|
|
||||||
No migration order or claim about individual applications' adoption is recorded
|
|
||||||
here; the [consumer guide](../consumers/guide.md) and maintained examples provide
|
|
||||||
integration guidance.
|
|
||||||
183
docs/roadmap/future.md
Normal file
183
docs/roadmap/future.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Future work
|
||||||
|
|
||||||
|
Status: pending release and integration work, plus future capability proposals.
|
||||||
|
No delivery dates are selected here.
|
||||||
|
|
||||||
|
## Purpose and authority
|
||||||
|
|
||||||
|
Completed roadmaps, implementation plans, and review findings have been retired.
|
||||||
|
This document preserves unfinished work and unselected ideas under the
|
||||||
|
[release policy](../policy/release.md#release-documentation-and-preparation).
|
||||||
|
The completed ConfigKit adoption, reporting, dependency alignment, release-tooling,
|
||||||
|
and remediation records remain available at the
|
||||||
|
[retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap).
|
||||||
|
[Earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap)
|
||||||
|
also remain in Git history. Completed records are not assignments to restart work.
|
||||||
|
|
||||||
|
Accepted [ADRs](../adr/) and the [architecture policy](../policy/architecture.md)
|
||||||
|
govern any future implementation. The [CLI reference](../cli.md), public GoDoc,
|
||||||
|
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
|
||||||
|
an idea here does not select its syntax, API, priority, or delivery date.
|
||||||
|
|
||||||
|
## Pending release and integration work
|
||||||
|
|
||||||
|
### ckconfig release and hosted delivery
|
||||||
|
|
||||||
|
The ckconfig v0.3.0 candidate adopts ConfigKit v1.1.0 and Go 1.27.0; its
|
||||||
|
implementation and audits are complete. Its
|
||||||
|
[candidate release note](../../integrations/ckconfig/docs/releases/v0.3.0.md)
|
||||||
|
is prepared, but source publication and hosted delivery remain pending. Complete
|
||||||
|
those operations when requested through the [release procedure](../release.md),
|
||||||
|
using the existing fresh-version guards and reporting source publication and
|
||||||
|
hosted Gitea delivery separately. Publishing this roadmap cleanup does not
|
||||||
|
publish a module version tag or deliver a release page.
|
||||||
|
|
||||||
|
### ckprompt dependency migration and workspace alignment
|
||||||
|
|
||||||
|
Review the upstream PromptKit vanity release and select compatible ConfigKit
|
||||||
|
and PromptKit dependencies before migrating ckprompt's adapters and maintained
|
||||||
|
composed consumer. The new ckconfig uses vanity ConfigKit types; ckprompt v0.2.0
|
||||||
|
still uses published ckconfig v0.2.0 and old-path ConfigKit v0.8.0. Their local
|
||||||
|
sources do not currently compose in the three-module workspace. See the
|
||||||
|
[consumer guide](../consumers/guide.md#module-selection) for the current module
|
||||||
|
relationship.
|
||||||
|
|
||||||
|
The follow-up includes migrating affected imports and public type boundaries,
|
||||||
|
selecting real released dependency versions and the required Go minimum,
|
||||||
|
updating notices and migration guidance, and validating both ckprompt's
|
||||||
|
independent graph and the aligned composed consumer. Restore three-module
|
||||||
|
workspace guidance when those dependencies align, updating the ignored workspace
|
||||||
|
Go version as needed. Until then, preserve local workspace files and validate
|
||||||
|
with workspace discovery off as described in the [development guide](../development.md).
|
||||||
|
No unreviewed PromptKit version or new compatibility bridge is selected here.
|
||||||
|
|
||||||
|
## Future capability targets
|
||||||
|
|
||||||
|
### Comparison extensions
|
||||||
|
|
||||||
|
Independent per-side domain selectors and an optional assertion mode remain
|
||||||
|
unselected extensions to the delivered ConfigKit comparison. Consider them only
|
||||||
|
for a concrete consumer need and a deliberate API/behavior decision under the
|
||||||
|
relevant ADRs.
|
||||||
|
|
||||||
|
### Configuration inspection extensions
|
||||||
|
|
||||||
|
The [completed ConfigKit v1.1.0 adoption review](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap/configkit-v1.1.0.md#selected-scope-decisions)
|
||||||
|
retained the following work after dependency and source-report adoption.
|
||||||
|
Metadata inspection is the recommended next functional sprint; the other
|
||||||
|
features have no selected sequence or delivery date. These proposals do not
|
||||||
|
establish command grammar, report schemas, or new provider requirements.
|
||||||
|
ConfigKit's public APIs own the evidence and its semantics.
|
||||||
|
|
||||||
|
- **Optional field metadata, path discovery, and history (OD-3).** Expose native
|
||||||
|
`Metadata.Lookup`, `IsExplicit`, `HistoricalPaths`, and `History` through an
|
||||||
|
optional provider capability. Design field and history reports together,
|
||||||
|
keeping existing providers usable. Distinguish effective paths from removed
|
||||||
|
historical paths: a failed `Lookup` must not suppress available `History`.
|
||||||
|
Validate that distinction and faithful projection of returned field/change
|
||||||
|
records without recreating upstream provenance logic. Historical enumeration
|
||||||
|
is not an exhaustive inventory of possible schema fields. This gives the
|
||||||
|
earlier field-origin inventory proposal a concrete, upstream-backed scope;
|
||||||
|
command names, selectors, and report shapes remain to be designed.
|
||||||
|
- **Failed-load source inventory (OD-4).** Present acquired source evidence from
|
||||||
|
`LoadError.Sources()` while retaining the failed outcome and diagnostics.
|
||||||
|
Design how an adapter returns its source facet on error and how result output
|
||||||
|
precedes terminal failure presentation. Cover acquisition and report-delivery
|
||||||
|
failures together; current diagnostic context remains available meanwhile.
|
||||||
|
- **Detailed comparison (OD-5).** Consider an additional form projecting native
|
||||||
|
`Difference` paths, both snapshots, presence/redaction fields, and
|
||||||
|
`HiddenChanged`. The current comparison retains the reduced
|
||||||
|
additions/changes/deletions report. Decide later whether a detailed form
|
||||||
|
complements it or replaces it through a deliberate compatibility change.
|
||||||
|
- **Structured diagnostics (OD-6).** Consider machine-readable native codes,
|
||||||
|
paths, severities, and locations alongside CommandKit's shared reporting
|
||||||
|
design. Define result/diagnostic channels, warning and terminal-failure
|
||||||
|
interaction, and delivery outcomes. A ckconfig-specific report remains an
|
||||||
|
alternative, with its cross-family alignment considered explicitly.
|
||||||
|
- **Source-ID filtering.** Consider selecting a record already available in the
|
||||||
|
source inventory. Native `Source(id)` lookup is a convenience; this proposal
|
||||||
|
need not introduce another command or require a lookup method from every
|
||||||
|
custom source provider.
|
||||||
|
|
||||||
|
Discovery, import defaults, collection identities, and field-level resolution
|
||||||
|
recording remain available through application loaders. Standard inspection of
|
||||||
|
resource contexts or secrets-directory bootstrap has no immediate proposal;
|
||||||
|
those capabilities primarily serve application loading and startup.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Reporting extensions
|
||||||
|
|
||||||
|
Error-conversion helpers such as `WarnError` remain deferred pending demonstrated
|
||||||
|
value; applications can already use `report.Warn(err.Error())`. Terminal redraw
|
||||||
|
and progress-bar presentation were outside the completed reporting scope and
|
||||||
|
remain unselected. Whole-run buffering to make failed runs show only errors
|
||||||
|
was also outside that scope. Any later extension needs a deliberate API and
|
||||||
|
presentation decision rather than assuming those behaviors are already provided.
|
||||||
|
|
||||||
|
### Release workflow extensions
|
||||||
|
|
||||||
|
Historical hosted-page backfilling and automated CI delivery remain separate
|
||||||
|
future choices. The current local page-only delivery workflow is implemented;
|
||||||
|
these extensions are not prerequisites for using it. The release procedure owns
|
||||||
|
delivery, immutable content, and recovery behavior.
|
||||||
|
|
||||||
|
Additional validation options, including race testing, full vet, compilation
|
||||||
|
matrices, size limits, and more forbidden-file paths, can be considered for
|
||||||
|
demonstrated needs. None is selected by retaining it here, and the existing
|
||||||
|
candidate checks do not become general prohibitions on development workspaces.
|
||||||
|
|
||||||
|
## Other future proposals
|
||||||
|
|
||||||
|
These capabilities remain outside the delivered scope and require a concrete
|
||||||
|
operator or consumer need and an appropriate ownership/behavior decision:
|
||||||
|
|
||||||
|
- 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 and generic value assignment. Field metadata and
|
||||||
|
provenance inspection are scoped under
|
||||||
|
[configuration inspection extensions](#configuration-inspection-extensions).
|
||||||
|
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
||||||
|
live provider probes.
|
||||||
|
- Additional component identity registration and domain artifact catalogs.
|
||||||
|
- Additional library families, including possible FeedKit integration; families
|
||||||
|
can define suitable verbs without requiring comparison or configuration scopes.
|
||||||
|
- Backend selection or replacement, a plugin framework, general dependency
|
||||||
|
injection, and concurrent-dispatch guarantees.
|
||||||
|
|
||||||
|
Execution, rendering, serving, and session operations remain application-owned.
|
||||||
|
Shared vocabulary is guidance under [ADR 0006](../adr/0006-command-grammar.md).
|
||||||
|
Backend replacement remains possible behind the accepted facade, subject to the
|
||||||
|
published API/CLI contracts and deliberate compatibility decisions.
|
||||||
|
|
||||||
|
## Possible further efficiency work
|
||||||
|
|
||||||
|
Selected-scope memoization and selector-membership indexes were deferred pending
|
||||||
|
measurements that justify their bookkeeping. Mutable backend reuse across
|
||||||
|
invocations, segmented aggregate YAML delivery, provider caching, consumer-loader
|
||||||
|
redesign, and a replacement backend were outside the completed performance
|
||||||
|
target. Reconsider them only with concrete motivation and review of ownership,
|
||||||
|
invocation state, native reports, and the affected contracts. Retained measurement
|
||||||
|
records are historical evidence, not performance guarantees or release gates.
|
||||||
|
|
||||||
|
## Consumer adoption
|
||||||
|
|
||||||
|
Consumer migrations remain separate from library delivery. Applications can
|
||||||
|
replace duplicated standard handlers and routing, then update their CLI docs
|
||||||
|
and subprocess consumers for intentional changes while retaining domain commands.
|
||||||
|
No migration order or claim about individual applications' adoption is recorded
|
||||||
|
here; the [consumer guide](../consumers/guide.md) and maintained examples provide
|
||||||
|
integration guidance.
|
||||||
38
examples/core/README.md
Normal file
38
examples/core/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 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 matched ckconfig and ckprompt releases 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()
|
||||||
|
}
|
||||||
403
execute.go
403
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)
|
||||||
|
if a == nil {
|
||||||
|
err := errors.New("cannot execute commands on a nil app")
|
||||||
|
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
err := errors.New("invocation context must not be nil")
|
||||||
|
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
|
||||||
|
}
|
||||||
|
if current.snapshot != nil && current.snapshot.completion && len(args) > 0 && args[0] == internalcobra.CompletionQueryToken {
|
||||||
|
return current.complete(args[1:])
|
||||||
|
}
|
||||||
routedSnapshot := current.snapshot
|
routedSnapshot := current.snapshot
|
||||||
parsed := current.parse(args)
|
current.route = resolveEnteredRoute(args, routedSnapshot)
|
||||||
|
current.recordRouteEvents()
|
||||||
|
parsed := current.parse(current.route.args)
|
||||||
|
current.reporter.applySelection(parsed.Reporting)
|
||||||
|
current.recordSelectionEvents(parsed)
|
||||||
|
current.submitDebugEvents()
|
||||||
|
var outcome Outcome
|
||||||
|
path := parsed.Path
|
||||||
|
handlerRan := false
|
||||||
if parsed.Failure != nil {
|
if parsed.Failure != nil {
|
||||||
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path)
|
outcome = Usage(parsed.Failure.Message)
|
||||||
}
|
} else if current.route.kind == routeDiscovery {
|
||||||
payload, ok := routedSnapshot.runners[parsed.Key]
|
if parsed.HelpRequested {
|
||||||
if !ok || payload == nil {
|
outcome = current.renderHelp(parsed.Path)
|
||||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
path = clonePath(current.route.enteredPath)
|
||||||
}
|
} else {
|
||||||
if parsed.HelpRequested || !payload.hasRun() {
|
entered := current.route.enteredPath.String()
|
||||||
if _, err := payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
|
outcome = Usage(fmt.Sprintf("incomplete command path %q; use %q --help", entered, entered))
|
||||||
return current.present(Usage(err), parsed.Path)
|
path = clonePath(current.route.enteredPath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload, ok := routedSnapshot.runners[parsed.Key]
|
||||||
|
if !ok || payload == nil {
|
||||||
|
outcome = current.renderHelp(parsed.Path)
|
||||||
|
} else if parsed.HelpRequested || !payload.hasRun() {
|
||||||
|
if _, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, false); err != nil {
|
||||||
|
outcome = classifyInputError(err)
|
||||||
|
} else {
|
||||||
|
outcome = current.renderHelp(parsed.Path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
run, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, true)
|
||||||
|
if err != nil {
|
||||||
|
outcome = classifyInputError(err)
|
||||||
|
} else {
|
||||||
|
handlerRan = true
|
||||||
|
outcome = run(current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
|
||||||
}
|
}
|
||||||
run, err := payload.prepare(parsed.FlagValues, parsed.Operands, true)
|
return current.finalize(outcome, path, handlerRan)
|
||||||
if err != nil {
|
|
||||||
return current.present(Usage(err), parsed.Path)
|
|
||||||
}
|
|
||||||
return current.present(run(current), parsed.Path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type execution struct {
|
type execution struct {
|
||||||
app *App
|
app *App
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
streams Streams
|
streams Streams
|
||||||
options commandmeta.ParseOptions
|
reporter *Reporter
|
||||||
snapshot *routingSnapshot
|
debugEvents []string
|
||||||
session *internalcobra.Session
|
options commandmeta.ParseOptions
|
||||||
|
snapshot *routingSnapshot
|
||||||
|
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,
|
||||||
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
|
reporter: newReporter(streams),
|
||||||
|
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 {
|
||||||
if streams.Err == nil {
|
case routeCanonical:
|
||||||
streams.Err = io.Discard
|
if entered == canonical {
|
||||||
}
|
if canonical == "" {
|
||||||
return streams
|
current.debugEvents = append(current.debugEvents, "selected the application root command")
|
||||||
}
|
} else {
|
||||||
|
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected command path %q", canonical))
|
||||||
func (a *App) present(outcome Outcome, path []string, streams Streams) Outcome {
|
}
|
||||||
return a.presentWithUsageLine(outcome, path, streams, a.usageLine)
|
} else {
|
||||||
}
|
current.debugEvents = append(current.debugEvents, fmt.Sprintf("resolved command alias %q to %q", entered, canonical))
|
||||||
|
|
||||||
func (a *App) presentWithUsageLine(outcome Outcome, path []string, streams Streams, usageLine func([]string) string) Outcome {
|
|
||||||
if outcome.Kind == "" && outcome.Status == 0 && outcome.Domain == "" &&
|
|
||||||
outcome.Cause == nil && outcome.DeliveryError == nil &&
|
|
||||||
!outcome.ReportIncomplete && len(outcome.Diagnostics) == 0 {
|
|
||||||
outcome.Kind = OutcomeSuccess
|
|
||||||
}
|
|
||||||
if outcome.DiagnosticDelivery == "" {
|
|
||||||
outcome.DiagnosticDelivery = DeliveryNone
|
|
||||||
}
|
|
||||||
streams = normalizeStreams(streams)
|
|
||||||
if outcome.DiagnosticDelivery == DeliveryComplete {
|
|
||||||
return outcome
|
|
||||||
}
|
|
||||||
diagnostics := append([]Diagnostic(nil), outcome.Diagnostics...)
|
|
||||||
if outcome.Cause != nil && !hasErrorDiagnostic(diagnostics) {
|
|
||||||
diagnostics = append(diagnostics, MessageDiagnostic(SeverityError, outcome.Cause.Error()))
|
|
||||||
}
|
|
||||||
if outcome.Kind == OutcomeUsage {
|
|
||||||
diagnostics = append(diagnostics, MessageDiagnostic(SeverityError, usageLine(path)))
|
|
||||||
}
|
|
||||||
outcome.Diagnostics = diagnostics
|
|
||||||
if len(diagnostics) == 0 {
|
|
||||||
return outcome
|
|
||||||
}
|
|
||||||
delivered := false
|
|
||||||
for _, diagnostic := range diagnostics {
|
|
||||||
if diagnostic.Write == nil {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
delivered = true
|
case routeDiscovery:
|
||||||
writer := &checkedWriter{writer: streams.Err}
|
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected discovery route for prefix %q", entered))
|
||||||
writeErr := diagnostic.Write(writer)
|
default:
|
||||||
if writer.failure != nil {
|
if entered != "" {
|
||||||
if writeErr == nil {
|
current.debugEvents = append(current.debugEvents, fmt.Sprintf("no canonical route matched command path %q", entered))
|
||||||
writeErr = writer.failure
|
}
|
||||||
} else if !errors.Is(writeErr, writer.failure) {
|
}
|
||||||
writeErr = errors.Join(writeErr, writer.failure)
|
}
|
||||||
|
|
||||||
|
func (current *execution) recordSelectionEvents(result commandmeta.Result) {
|
||||||
|
mode := "ordinary"
|
||||||
|
flagName := ""
|
||||||
|
switch result.Reporting.Mode {
|
||||||
|
case commandmeta.ReportingModeQuiet:
|
||||||
|
mode, flagName = "quiet", "quiet"
|
||||||
|
case commandmeta.ReportingModeVerbose:
|
||||||
|
mode, flagName = "verbose", "verbose"
|
||||||
|
case commandmeta.ReportingModeDebug:
|
||||||
|
mode, flagName = "debug", "debug"
|
||||||
|
}
|
||||||
|
event := "selected ordinary reporting mode"
|
||||||
|
if flagName != "" {
|
||||||
|
event = fmt.Sprintf("selected %s reporting mode", mode)
|
||||||
|
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
|
||||||
|
occurrence := result.FlagOccurrences[index]
|
||||||
|
if occurrence.Name != flagName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enabled, err := strconv.ParseBool(occurrence.RawValue)
|
||||||
|
if err != nil || !enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
canonical := "--" + flagName
|
||||||
|
if occurrence.Spelling != canonical {
|
||||||
|
event += fmt.Sprintf(" using %s (alias for %s)", occurrence.Spelling, canonical)
|
||||||
|
} else {
|
||||||
|
event += fmt.Sprintf(" using %s", canonical)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.debugEvents = append(current.debugEvents, event)
|
||||||
|
if result.Reporting.ProgressSet {
|
||||||
|
state := "disabled"
|
||||||
|
if result.Reporting.Progress {
|
||||||
|
state = "enabled"
|
||||||
|
}
|
||||||
|
event := "explicitly " + state + " progress output"
|
||||||
|
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
|
||||||
|
occurrence := result.FlagOccurrences[index]
|
||||||
|
if occurrence.Name == "progress" {
|
||||||
|
if occurrence.Spelling != "--progress" {
|
||||||
|
event += fmt.Sprintf(" using %s (alias for --progress)", occurrence.Spelling)
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if writeErr != nil {
|
current.debugEvents = append(current.debugEvents, event)
|
||||||
return deliveryFailure(outcome, writeErr)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (current *execution) submitDebugEvents() {
|
||||||
|
events := current.debugEvents
|
||||||
|
current.debugEvents = nil
|
||||||
|
for _, event := range events {
|
||||||
|
_ = current.reporter.Debug(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (current *execution) finalize(outcome Outcome, path []string, handlerRan bool) Outcome {
|
||||||
|
if outcome.Kind == OutcomeUsage {
|
||||||
|
details := append([]Detail(nil), outcome.Explanation.Details...)
|
||||||
|
details = append(details, func(w io.Writer) error {
|
||||||
|
line := current.usageText(path)
|
||||||
|
if !strings.HasSuffix(line, "\n") {
|
||||||
|
line += "\n"
|
||||||
|
}
|
||||||
|
_, err := io.WriteString(w, line)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
outcome.Explanation.Details = details
|
||||||
|
}
|
||||||
|
var completion func(*Reporter)
|
||||||
|
if handlerRan {
|
||||||
|
completion = func(reporter *Reporter) {
|
||||||
|
kind := outcome.Kind
|
||||||
|
if kind == "" && outcome.Status == 0 && outcome.Domain == "" {
|
||||||
|
kind = OutcomeSuccess
|
||||||
|
}
|
||||||
|
path := current.route.canonicalPath.String()
|
||||||
|
_ = reporter.Debug(fmt.Sprintf("handler completed for %q with %s outcome and status %d", path, kind, outcome.Status))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if delivered {
|
return current.reporter.finalize(outcome, completion)
|
||||||
outcome.DiagnosticDelivery = DeliveryComplete
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
return commandkit.Failure("The operation failed.").WithCause(cause)
|
||||||
if err != nil {
|
},
|
||||||
t.Fatal(err)
|
})
|
||||||
}
|
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||||
var diagnostics bytes.Buffer
|
if err != nil {
|
||||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostics})
|
t.Fatal(err)
|
||||||
want := commandkit.DeliveryNone
|
}
|
||||||
if base.Cause != nil {
|
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
|
||||||
want = commandkit.DeliveryComplete
|
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, cause) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
|
||||||
}
|
t.Fatalf("nil diagnostic destination changed the outcome: %#v", result)
|
||||||
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 }
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ itself.
|
|||||||
|
|
||||||
## Module
|
## Module
|
||||||
|
|
||||||
The module path is
|
The module path is `go.maximumdirect.net/ckconfig`. The prepared v0.3.0
|
||||||
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig`. Its first
|
candidate requires Go 1.27.0 and uses `go.maximumdirect.net/commandkit v0.3.0` and
|
||||||
release is prepared as `v0.1.0`. It uses the core `commandkit` package and
|
`go.maximumdirect.net/configkit v1.1.0`; it does not import PromptKit. See the
|
||||||
ConfigKit; it does not import PromptKit. See the
|
|
||||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
|
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
|
||||||
for exact callback and lifetime contracts.
|
for exact callback and lifetime contracts.
|
||||||
|
|
||||||
@@ -27,9 +26,9 @@ scopes, selectors, input checks, and state shared by the selected acquisitions
|
|||||||
or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed
|
or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed
|
||||||
ConfigKit loader. The `config` command family exposes scope discovery and the
|
ConfigKit loader. The `config` command family exposes scope discovery and the
|
||||||
validate, show, sources, explain, and diff operations supported by the declared
|
validate, show, sources, explain, and diff operations supported by the declared
|
||||||
scopes. Source reports include native destination, placement, unresolved
|
scopes. Source reports include native destination, placement, destination
|
||||||
destination, and source version-check evidence. Comparison returns ConfigKit's
|
fallback, version evidence, and empty-import selections. Comparison returns
|
||||||
native records to the YAML report path; the
|
ConfigKit's native records to the YAML report path; the
|
||||||
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#compare-configuration-selections)
|
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#compare-configuration-selections)
|
||||||
owns its operator details.
|
owns its operator details.
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type adapterSettings struct {
|
type adapterSettings struct {
|
||||||
@@ -27,7 +27,7 @@ func adapterDefinition() configkit.Definition[adapterSettings] {
|
|||||||
validation.Warning(configkit.Keys("greeting"), "using the default greeting")
|
validation.Warning(configkit.Keys("greeting"), "using the default greeting")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Secret: func(path configkit.Path) bool { return path.String() == "/secret" },
|
Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ func TestFromConfigKitCompareIgnoresDefaultAndResolutionHistoryForEqualValues(t
|
|||||||
Resolve: func(value *resolvedSettings, resolution *configkit.Resolution) error {
|
Resolve: func(value *resolvedSettings, resolution *configkit.Resolution) error {
|
||||||
if value.Greeting != "resolved" {
|
if value.Greeting != "resolved" {
|
||||||
value.Greeting = "resolved"
|
value.Greeting = "resolved"
|
||||||
resolution.Record(configkit.Keys("greeting"), configkit.ChangeNormalization, "canonical greeting")
|
resolution.RecordNormalization(configkit.Keys("greeting"), "canonical greeting")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestComparisonCapabilitiesAreIndependentAndCaptured(t *testing.T) {
|
func TestComparisonCapabilitiesAreIndependentAndCaptured(t *testing.T) {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestComparisonSelectionInheritsFieldsIndependently(t *testing.T) {
|
func TestComparisonSelectionInheritsFieldsIndependently(t *testing.T) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) {
|
func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) {
|
||||||
|
|||||||
11
integrations/ckconfig/completion.go
Normal file
11
integrations/ckconfig/completion.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import commandkit "go.maximumdirect.net/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 "go.maximumdirect.net/commandkit"
|
||||||
|
configkit "go.maximumdirect.net/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromConfigKit adapts a typed ConfigKit loader to all five native
|
// FromConfigKit adapts a typed ConfigKit loader to all five native
|
||||||
@@ -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,67 +95,110 @@ 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 {
|
}
|
||||||
if err := writeDiagnosticText(writer, heading); err != nil {
|
return []commandkit.Diagnostic{warning}, explanationWithConfigKitRecords(heading, sources, terminal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func explanationWithConfigKitRecords(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Explanation {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return commandkit.Explanation{}
|
||||||
|
}
|
||||||
|
return commandkit.Explanation{Details: []commandkit.Detail{configKitDiagnosticWriter(heading, sources, records)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configKitDiagnosticWriter(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Detail {
|
||||||
|
return func(writer io.Writer) error {
|
||||||
|
if err := writeDiagnosticText(writer, heading); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(sources) > 0 {
|
||||||
|
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(sources) > 0 {
|
for _, source := range sources {
|
||||||
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
|
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
|
||||||
|
if err := writeDiagnosticText(writer, line); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, source := range sources {
|
|
||||||
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
|
|
||||||
if err := writeDiagnosticText(writer, line); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return configkit.WriteDiagnostics(writer, records)
|
}
|
||||||
},
|
return configkit.WriteDiagnostics(writer, records)
|
||||||
}}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneConfigKitDiagnostics(records []configkit.Diagnostic) []configkit.Diagnostic {
|
||||||
|
cloned := append([]configkit.Diagnostic(nil), records...)
|
||||||
|
for index := range cloned {
|
||||||
|
if records[index].Primary != nil {
|
||||||
|
primary := *records[index].Primary
|
||||||
|
cloned[index].Primary = &primary
|
||||||
|
}
|
||||||
|
cloned[index].Related = append([]configkit.Location(nil), records[index].Related...)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendConfigKitExplanation(left, right commandkit.Explanation) commandkit.Explanation {
|
||||||
|
if left.Message == "" {
|
||||||
|
left.Message = right.Message
|
||||||
|
}
|
||||||
|
left.Details = append(left.Details, right.Details...)
|
||||||
|
return left
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeDiagnosticText(writer io.Writer, value string) error {
|
func writeDiagnosticText(writer io.Writer, value string) error {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Selection carries the configuration and overlay choices supplied for a
|
// Selection carries the configuration and overlay choices supplied for a
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWithScopeErrorCopiesAndRetainsCauses(t *testing.T) {
|
func TestWithScopeErrorCopiesAndRetainsCauses(t *testing.T) {
|
||||||
@@ -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{}
|
||||||
|
|||||||
@@ -4,40 +4,33 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -241,7 +273,7 @@ func TestConfigDiffExecutesConcealedChangesAndEmptyResults(t *testing.T) {
|
|||||||
if selection.Config.Set && selection.Config.Value == "after" {
|
if selection.Config.Set && selection.Config.Value == "after" {
|
||||||
value = "changed-secret"
|
value = "changed-secret"
|
||||||
}
|
}
|
||||||
return configkit.Load(configkit.Mapping("selected", map[string]any{"secret": value}), configkit.Options[settings]{Definition: configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}})
|
return configkit.Load(configkit.Mapping("selected", map[string]any{"secret": value}), configkit.Options[settings]{Definition: configkit.Definition[settings]{Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" }}})
|
||||||
}))
|
}))
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "after"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "after"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ func TestRenderDiffReportPreservesNativeMasksAndMarkerLookingStrings(t *testing.
|
|||||||
Public string `yaml:"public"`
|
Public string `yaml:"public"`
|
||||||
Secret string `yaml:"secret"`
|
Secret string `yaml:"secret"`
|
||||||
}
|
}
|
||||||
definition := configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}
|
definition := configkit.Definition[settings]{Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" }}
|
||||||
load := func(public, secret string) *configkit.Result[settings] {
|
load := func(public, secret string) *configkit.Result[settings] {
|
||||||
result, err := configkit.Load(configkit.Mapping("configuration", map[string]any{"public": public, "secret": secret}), configkit.Options[settings]{Definition: definition})
|
result, err := configkit.Load(configkit.Mapping("configuration", map[string]any{"public": public, "secret": secret}), configkit.Options[settings]{Definition: definition})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
// Package ckconfig provides CommandKit's optional ConfigKit integration. Its
|
// Package ckconfig, provided by go.maximumdirect.net/ckconfig, provides
|
||||||
// declarations install the standard config command family through the core
|
// CommandKit's optional ConfigKit v1.1.0 integration, requiring Go 1.27.0. Its
|
||||||
// commandkit package. Source reports preserve ConfigKit's destination,
|
// canonical Command declaration installs the standard config command family
|
||||||
// placement, and version-check evidence; importing ckconfig alone installs no
|
// through the core commandkit package. Applications can install CommandKit
|
||||||
// commands.
|
// RoutingRule declarations alongside it through commandkit.New or
|
||||||
|
// App.Register. Source reports preserve ConfigKit's destination, placement,
|
||||||
|
// version, and empty-import 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
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
## ConfigKit
|
## ConfigKit
|
||||||
|
|
||||||
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.8.0`.
|
Selected module: `go.maximumdirect.net/configkit` at `v1.1.0`.
|
||||||
|
|
||||||
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/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/LICENSE).
|
[LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5).
|
[Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9).
|
||||||
|
|
||||||
## go-yaml v4
|
## go-yaml v4
|
||||||
|
|
||||||
@@ -153,16 +153,16 @@ License and notices: [MIT](licenses/MIT.txt) |
|
|||||||
|
|
||||||
## ReleaseKit
|
## ReleaseKit
|
||||||
|
|
||||||
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.2` (transitive Go tool requirement; not a runtime import).
|
Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (transitive Go tool requirement; 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
|
||||||
|
|
||||||
|
|||||||
35
integrations/ckconfig/docs/releases/v0.2.0.md
Normal file
35
integrations/ckconfig/docs/releases/v0.2.0.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# CommandKit ckconfig v0.2.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
CommandKit ckconfig v0.2.0 moves the integration to
|
||||||
|
`go.maximumdirect.net/ckconfig` and uses `go.maximumdirect.net/commandkit v0.3.0`
|
||||||
|
with ConfigKit v0.8.0. It adopts core's invocation reporting and completion
|
||||||
|
contracts while retaining ConfigKit-owned inspection and comparison behavior.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Replace imports of `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig`
|
||||||
|
with `go.maximumdirect.net/ckconfig`, and update core imports to
|
||||||
|
`go.maximumdirect.net/commandkit`. Select ckconfig v0.2.0 and core v0.3.0
|
||||||
|
as a matched set. Old and new core paths have distinct Go type identities.
|
||||||
|
Applications also using PromptKit should move to the forthcoming matched
|
||||||
|
`go.maximumdirect.net/ckprompt v0.2.0` release.
|
||||||
|
|
||||||
|
Handlers use `Invocation.Reporter()` for output and categorized messages;
|
||||||
|
terminal failures carry an explanation and optional cause. The integration
|
||||||
|
submits provider diagnostics through the invocation reporter, respecting shared
|
||||||
|
reporting flags. Update callers that used `Invocation.Streams`,
|
||||||
|
`Outcome.Diagnostics`, or the old error-first failure API as described in the
|
||||||
|
[core migration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/consumers/guide.md#invocation-reporting).
|
||||||
|
The standard config commands also provide completion hints for applicable
|
||||||
|
selectors and file paths when the application installs core's completion command.
|
||||||
|
|
||||||
|
Go 1.26.0 remains the consumer minimum. Importing ckconfig does not install
|
||||||
|
commands; pass its declarations to `commandkit.New` or `App.Register`.
|
||||||
|
See the [module README](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.2.0/integrations/ckconfig/README.md)
|
||||||
|
and [consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.2.0/docs/consumers/guide.md#configkit-scopes).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.2.0/integrations/ckconfig/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.2.0/integrations/ckconfig/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.2.0/integrations/ckconfig/docs/oss/attribution.md)
|
||||||
25
integrations/ckconfig/docs/releases/v0.3.0.md
Normal file
25
integrations/ckconfig/docs/releases/v0.3.0.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# CommandKit ckconfig v0.3.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This candidate adopts `go.maximumdirect.net/configkit v1.1.0` and requires Go
|
||||||
|
1.27.0, while retaining `go.maximumdirect.net/commandkit v0.3.0`. It updates
|
||||||
|
configuration source reports to expose ConfigKit's current evidence.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Change ConfigKit imports from `gitea.maximumdirect.net/eric/ConfigKit` to
|
||||||
|
`go.maximumdirect.net/configkit`. Values from the two paths have distinct Go
|
||||||
|
type identities. Update application loaders and provider values together with
|
||||||
|
ckconfig. Published ckprompt v0.2.0 continues to use ckconfig v0.2.0 and the old
|
||||||
|
ConfigKit identity; it does not compose with this candidate.
|
||||||
|
|
||||||
|
Source reports rename `destination_unresolved` to `destination_is_fallback` and
|
||||||
|
`version_check` to `version_evidence`, use the native `main` layer name, and add
|
||||||
|
`empty_import_selections` records (or `[]` when empty). Update report consumers.
|
||||||
|
See the [consumer migration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.3.0/docs/consumers/guide.md#configkit-v11-migration)
|
||||||
|
for links to upstream configuration and API changes.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.3.0/integrations/ckconfig/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.3.0/integrations/ckconfig/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckconfig/v0.3.0/integrations/ckconfig/docs/oss/attribution.md)
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
# Simple consumer
|
# Simple consumer
|
||||||
|
|
||||||
|
This example imports `go.maximumdirect.net/ckconfig` and
|
||||||
|
`go.maximumdirect.net/commandkit`; use the ckconfig v0.3.0 candidate with core
|
||||||
|
v0.3.0, ConfigKit v1.1.0, and Go 1.27.0. Set `GOWORK=off GOTOOLCHAIN=local`
|
||||||
|
for the commands below.
|
||||||
|
|
||||||
Run the maintained example with:
|
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 +22,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,15 +3,19 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
"go.maximumdirect.net/ckconfig"
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
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,10 +4,11 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
"go.maximumdirect.net/ckconfig"
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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,12 +2,11 @@ package ckconfig
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type explanationReport struct {
|
type explanationReport struct {
|
||||||
@@ -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)
|
||||||
var report bytes.Buffer
|
})
|
||||||
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
|
if err != nil && invocation.Context.Err() != nil {
|
||||||
return configOperationFailure(err, diagnostics, false)
|
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
|
||||||
}
|
|
||||||
if err := invocation.Context.Err(); err != nil {
|
|
||||||
return configOperationFailure(err, diagnostics, false)
|
|
||||||
}
|
|
||||||
if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil {
|
|
||||||
return configDeliveryFailure(err, diagnostics, true)
|
|
||||||
}
|
|
||||||
outcome := commandkit.Success()
|
|
||||||
outcome.Diagnostics = diagnostics
|
|
||||||
return outcome
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path configkit.Path, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
|
||||||
checked := &checkedWriter{writer: writer}
|
|
||||||
renderErr := reader.WriteExplanation(checked, path)
|
|
||||||
if checked.failure != nil {
|
|
||||||
if renderErr == nil {
|
|
||||||
renderErr = checked.failure
|
|
||||||
} else {
|
|
||||||
renderErr = errors.Join(renderErr, checked.failure)
|
|
||||||
}
|
}
|
||||||
|
return commandkit.Success()
|
||||||
}
|
}
|
||||||
if renderErr != nil {
|
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||||
outcome := configOperationFailure(renderErr, diagnostics, true)
|
var report bytes.Buffer
|
||||||
outcome.DeliveryError = checked.failure
|
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
|
||||||
return outcome
|
return nil, err
|
||||||
}
|
}
|
||||||
outcome := commandkit.Success()
|
return report.Bytes(), nil
|
||||||
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"
|
||||||
@@ -8,9 +9,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/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 {
|
||||||
} else {
|
return err
|
||||||
output = []byte(formatScopeInventory(prepared.inventory))
|
}
|
||||||
}
|
} else {
|
||||||
if err != nil {
|
_, _ = report.WriteString(formatScopeInventory(prepared.inventory))
|
||||||
return commandkit.Failure(err)
|
}
|
||||||
}
|
_, err := writer.Write(report.Bytes())
|
||||||
if err := writeConfigReport(invocation.Streams.Out, output); err != nil {
|
return err
|
||||||
return incompleteConfigReport(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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestScopeDiscoveryIsOrderedAndDoesNotUseSuiteFactories(t *testing.T) {
|
func TestScopeDiscoveryIsOrderedAndDoesNotUseSuiteFactories(t *testing.T) {
|
||||||
@@ -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,
|
||||||
@@ -293,6 +441,7 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
|
|||||||
value: configkit.Explanation{
|
value: configkit.Explanation{
|
||||||
Path: configkit.Path{},
|
Path: configkit.Path{},
|
||||||
Effective: configkit.ValueSnapshot{Present: true, Redacted: true},
|
Effective: configkit.ValueSnapshot{Present: true, Redacted: true},
|
||||||
|
Origin: configkit.Origin{Layer: configkit.LayerMain},
|
||||||
History: []configkit.ValueChange{{
|
History: []configkit.ValueChange{{
|
||||||
Before: configkit.ValueSnapshot{Present: true, Redacted: true},
|
Before: configkit.ValueSnapshot{Present: true, Redacted: true},
|
||||||
After: configkit.ValueSnapshot{Present: true, Redacted: true},
|
After: configkit.ValueSnapshot{Present: true, Redacted: true},
|
||||||
@@ -312,7 +461,7 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
|
|||||||
if outcome.Kind != commandkit.OutcomeSuccess || acquireCalls != 1 || reader.path.String() != "" {
|
if outcome.Kind != commandkit.OutcomeSuccess || acquireCalls != 1 || reader.path.String() != "" {
|
||||||
t.Fatalf("root explanation outcome=%#v calls=%d path=%q", outcome, acquireCalls, reader.path.String())
|
t.Fatalf("root explanation outcome=%#v calls=%d path=%q", outcome, acquireCalls, reader.path.String())
|
||||||
}
|
}
|
||||||
if strings.Contains(output.String(), "APP_TOKEN_VALUE") || !strings.Contains(output.String(), "redacted: true") || !strings.Contains(output.String(), "history:") || !strings.Contains(output.String(), "dependencies: []") || !strings.Contains(output.String(), "name: APP_TOKEN") {
|
if strings.Contains(output.String(), "APP_TOKEN_VALUE") || !strings.Contains(output.String(), "redacted: true") || !strings.Contains(output.String(), "layer: main") || !strings.Contains(output.String(), "history:") || !strings.Contains(output.String(), "dependencies: []") || !strings.Contains(output.String(), "name: APP_TOKEN") {
|
||||||
t.Fatalf("safe explanation report = %s", output.String())
|
t.Fatalf("safe explanation report = %s", output.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,13 +473,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 +513,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 +525,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 +547,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,10 +1,10 @@
|
|||||||
module gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig
|
module go.maximumdirect.net/ckconfig
|
||||||
|
|
||||||
go 1.26.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/CommandKit v0.2.0
|
go.maximumdirect.net/commandkit v0.3.0
|
||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0
|
go.maximumdirect.net/configkit v1.1.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
|
|
||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
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=
|
||||||
@@ -8,6 +6,10 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
|||||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
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=
|
||||||
|
go.maximumdirect.net/commandkit v0.3.0 h1:RCmg2GS8uzlLSJCIqarpn1uFDec0W3ylNNRRPT6kUFE=
|
||||||
|
go.maximumdirect.net/commandkit v0.3.0/go.mod h1:ZS98DewKNVBygKVDoh5MFD09CnPlP37dS9pVsID5Eoc=
|
||||||
|
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
|
||||||
|
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
||||||
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=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type configCommandInput[I, S any] struct {
|
type configCommandInput[I, S any] struct {
|
||||||
@@ -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 "go.maximumdirect.net/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 "go.maximumdirect.net/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,13 +2,12 @@ package ckconfig
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type effectiveDocument struct {
|
type effectiveDocument struct {
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||||
|
for index, document := range documents {
|
||||||
|
if err := invocation.Context.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var native bytes.Buffer
|
||||||
|
if err := document.view.Effective.WriteEffective(&native); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := invocation.Context.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes())
|
||||||
|
if index > 0 && hasDirective {
|
||||||
|
if _, err := io.WriteString(writer, "...\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' {
|
||||||
|
framed = append(framed, '\n')
|
||||||
|
}
|
||||||
|
if _, err := writer.Write(framed); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if renderErr != nil {
|
return nil
|
||||||
outcome := configOperationFailure(renderErr, diagnostics, true)
|
})
|
||||||
outcome.DeliveryError = checked.failure
|
if err != nil && invocation.Context.Err() != nil {
|
||||||
return outcome
|
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
|
||||||
}
|
|
||||||
outcome := commandkit.Success()
|
|
||||||
outcome.Diagnostics = diagnostics
|
|
||||||
return outcome
|
|
||||||
}
|
}
|
||||||
|
return commandkit.Success()
|
||||||
written := false
|
|
||||||
for index, document := range documents {
|
|
||||||
if err := invocation.Context.Err(); err != nil {
|
|
||||||
return configOperationFailure(err, diagnostics, written)
|
|
||||||
}
|
|
||||||
var native bytes.Buffer
|
|
||||||
if err := document.view.Effective.WriteEffective(&native); err != nil {
|
|
||||||
return configOperationFailure(err, diagnostics, written)
|
|
||||||
}
|
|
||||||
if err := invocation.Context.Err(); err != nil {
|
|
||||||
return configOperationFailure(err, diagnostics, written)
|
|
||||||
}
|
|
||||||
framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes())
|
|
||||||
if index > 0 && hasDirective {
|
|
||||||
if err := writeConfigReport(invocation.Streams.Out, []byte("...\n")); err != nil {
|
|
||||||
return configDeliveryFailure(err, diagnostics, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' {
|
|
||||||
framed = append(framed, '\n')
|
|
||||||
}
|
|
||||||
if err := writeConfigReport(invocation.Streams.Out, framed); err != nil {
|
|
||||||
return configDeliveryFailure(err, diagnostics, true)
|
|
||||||
}
|
|
||||||
written = true
|
|
||||||
}
|
|
||||||
outcome := commandkit.Success()
|
|
||||||
outcome.Diagnostics = diagnostics
|
|
||||||
return outcome
|
|
||||||
}
|
|
||||||
|
|
||||||
type checkedWriter struct {
|
|
||||||
writer io.Writer
|
|
||||||
failure error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (writer *checkedWriter) Write(content []byte) (int, error) {
|
|
||||||
if writer.failure != nil {
|
|
||||||
return 0, writer.failure
|
|
||||||
}
|
|
||||||
written, err := writer.writer.Write(content)
|
|
||||||
if err == nil && written != len(content) {
|
|
||||||
err = io.ErrShortWrite
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
writer.failure = err
|
|
||||||
}
|
|
||||||
return written, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type yamlPreamble struct {
|
type yamlPreamble struct {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,17 +17,24 @@ type sourceReport struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type sourceDTO struct {
|
type sourceDTO struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Label string `yaml:"label"`
|
Label string `yaml:"label"`
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Destination string `yaml:"destination"`
|
Destination string `yaml:"destination"`
|
||||||
Placement string `yaml:"placement"`
|
Placement string `yaml:"placement"`
|
||||||
DestinationUnresolved bool `yaml:"destination_unresolved"`
|
DestinationIsFallback bool `yaml:"destination_is_fallback"`
|
||||||
Layer string `yaml:"layer"`
|
Layer string `yaml:"layer"`
|
||||||
Kind string `yaml:"kind"`
|
Kind string `yaml:"kind"`
|
||||||
UnderlayIndex int `yaml:"underlay_index"`
|
UnderlayIndex int `yaml:"underlay_index"`
|
||||||
ImportedFrom *locationDTO `yaml:"imported_from"`
|
ImportedFrom *locationDTO `yaml:"imported_from"`
|
||||||
VersionCheck *sourceVersionCheckDTO `yaml:"version_check"`
|
VersionEvidence *sourceVersionEvidenceDTO `yaml:"version_evidence"`
|
||||||
|
EmptyImportSelections []emptyImportSelectionDTO `yaml:"empty_import_selections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type emptyImportSelectionDTO struct {
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
Location locationDTO `yaml:"location"`
|
||||||
|
Selector string `yaml:"selector"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type locationDTO struct {
|
type locationDTO struct {
|
||||||
@@ -36,7 +43,7 @@ type locationDTO struct {
|
|||||||
Column int `yaml:"column"`
|
Column int `yaml:"column"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sourceVersionCheckDTO struct {
|
type sourceVersionEvidenceDTO struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Version versionDTO `yaml:"version"`
|
Version versionDTO `yaml:"version"`
|
||||||
Location *locationDTO `yaml:"location"`
|
Location *locationDTO `yaml:"location"`
|
||||||
@@ -52,25 +59,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()
|
||||||
@@ -86,17 +96,18 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
|
|||||||
Path: source.Path,
|
Path: source.Path,
|
||||||
Destination: source.Destination.String(),
|
Destination: source.Destination.String(),
|
||||||
Placement: placement,
|
Placement: placement,
|
||||||
DestinationUnresolved: source.DestinationUnresolved,
|
DestinationIsFallback: source.DestinationIsFallback,
|
||||||
Layer: string(source.Layer),
|
Layer: string(source.Layer),
|
||||||
Kind: string(source.Kind),
|
Kind: string(source.Kind),
|
||||||
UnderlayIndex: source.UnderlayIndex,
|
UnderlayIndex: source.UnderlayIndex,
|
||||||
|
EmptyImportSelections: make([]emptyImportSelectionDTO, 0, len(source.EmptyImportSelections)),
|
||||||
}
|
}
|
||||||
if source.ImportedFrom != nil {
|
if source.ImportedFrom != nil {
|
||||||
item.ImportedFrom = &locationDTO{SourceID: source.ImportedFrom.SourceID, Line: source.ImportedFrom.Line, Column: source.ImportedFrom.Column}
|
item.ImportedFrom = &locationDTO{SourceID: source.ImportedFrom.SourceID, Line: source.ImportedFrom.Line, Column: source.ImportedFrom.Column}
|
||||||
}
|
}
|
||||||
if source.VersionCheck != nil {
|
if source.VersionEvidence != nil {
|
||||||
check := source.VersionCheck
|
check := source.VersionEvidence
|
||||||
item.VersionCheck = &sourceVersionCheckDTO{
|
item.VersionEvidence = &sourceVersionEvidenceDTO{
|
||||||
Path: check.Path.String(),
|
Path: check.Path.String(),
|
||||||
Version: versionDTO{
|
Version: versionDTO{
|
||||||
Present: check.Version.Present,
|
Present: check.Version.Present,
|
||||||
@@ -106,28 +117,28 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
|
|||||||
Redacted: check.Redacted,
|
Redacted: check.Redacted,
|
||||||
}
|
}
|
||||||
if check.Location != nil {
|
if check.Location != nil {
|
||||||
item.VersionCheck.Location = &locationDTO{SourceID: check.Location.SourceID, Line: check.Location.Line, Column: check.Location.Column}
|
item.VersionEvidence.Location = &locationDTO{SourceID: check.Location.SourceID, Line: check.Location.Line, Column: check.Location.Column}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, selection := range source.EmptyImportSelections {
|
||||||
|
item.EmptyImportSelections = append(item.EmptyImportSelections, emptyImportSelectionDTO{Path: selection.Path.String(), Location: locationDTO{SourceID: selection.Location.SourceID, Line: selection.Location.Line, Column: selection.Location.Column}, Selector: string(selection.Selector)})
|
||||||
|
}
|
||||||
document.Sources = append(document.Sources, item)
|
document.Sources = append(document.Sources, item)
|
||||||
}
|
}
|
||||||
var report bytes.Buffer
|
|
||||||
if plan.format == "yaml" {
|
|
||||||
if err := encodeYAML(&report, document); err != nil {
|
|
||||||
return configOperationFailure(err, diagnostics, false)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
writeSourceReportText(&report, document)
|
|
||||||
}
|
|
||||||
if err := invocation.Context.Err(); err != nil {
|
if err := invocation.Context.Err(); err != nil {
|
||||||
return configOperationFailure(err, diagnostics, false)
|
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
|
||||||
}
|
}
|
||||||
if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil {
|
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||||
return configDeliveryFailure(err, diagnostics, true)
|
var report bytes.Buffer
|
||||||
}
|
if plan.format == "yaml" {
|
||||||
outcome := commandkit.Success()
|
if err := encodeYAML(&report, document); err != nil {
|
||||||
outcome.Diagnostics = diagnostics
|
return nil, err
|
||||||
return outcome
|
}
|
||||||
|
} else {
|
||||||
|
writeSourceReportText(&report, document)
|
||||||
|
}
|
||||||
|
return report.Bytes(), nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSourceReportText(writer *bytes.Buffer, document sourceReport) {
|
func writeSourceReportText(writer *bytes.Buffer, document sourceReport) {
|
||||||
@@ -143,7 +154,7 @@ func writeSourceReportText(writer *bytes.Buffer, document sourceReport) {
|
|||||||
_, _ = fmt.Fprintf(writer, " path: %s\n", strconv.Quote(source.Path))
|
_, _ = fmt.Fprintf(writer, " path: %s\n", strconv.Quote(source.Path))
|
||||||
_, _ = fmt.Fprintf(writer, " destination: %s\n", strconv.Quote(source.Destination))
|
_, _ = fmt.Fprintf(writer, " destination: %s\n", strconv.Quote(source.Destination))
|
||||||
_, _ = fmt.Fprintf(writer, " placement: %s\n", strconv.Quote(source.Placement))
|
_, _ = fmt.Fprintf(writer, " placement: %s\n", strconv.Quote(source.Placement))
|
||||||
_, _ = fmt.Fprintf(writer, " destination_unresolved: %t\n", source.DestinationUnresolved)
|
_, _ = fmt.Fprintf(writer, " destination_is_fallback: %t\n", source.DestinationIsFallback)
|
||||||
_, _ = fmt.Fprintf(writer, " layer: %s\n", strconv.Quote(source.Layer))
|
_, _ = fmt.Fprintf(writer, " layer: %s\n", strconv.Quote(source.Layer))
|
||||||
_, _ = fmt.Fprintf(writer, " kind: %s\n", strconv.Quote(source.Kind))
|
_, _ = fmt.Fprintf(writer, " kind: %s\n", strconv.Quote(source.Kind))
|
||||||
_, _ = fmt.Fprintf(writer, " underlay_index: %d\n", source.UnderlayIndex)
|
_, _ = fmt.Fprintf(writer, " underlay_index: %d\n", source.UnderlayIndex)
|
||||||
@@ -153,21 +164,29 @@ func writeSourceReportText(writer *bytes.Buffer, document sourceReport) {
|
|||||||
_, _ = fmt.Fprintf(writer, " imported_from: {source_id: %s, line: %d, column: %d}\n",
|
_, _ = fmt.Fprintf(writer, " imported_from: {source_id: %s, line: %d, column: %d}\n",
|
||||||
strconv.Quote(source.ImportedFrom.SourceID), source.ImportedFrom.Line, source.ImportedFrom.Column)
|
strconv.Quote(source.ImportedFrom.SourceID), source.ImportedFrom.Line, source.ImportedFrom.Column)
|
||||||
}
|
}
|
||||||
if source.VersionCheck == nil {
|
if source.VersionEvidence == nil {
|
||||||
_, _ = writer.WriteString(" version_check: null\n")
|
_, _ = writer.WriteString(" version_evidence: null\n")
|
||||||
continue
|
|
||||||
}
|
|
||||||
check := source.VersionCheck
|
|
||||||
_, _ = fmt.Fprintf(writer, " version_check:\n path: %s\n", strconv.Quote(check.Path))
|
|
||||||
_, _ = fmt.Fprintf(writer, " version:\n present: %t\n null: %t\n text: %s\n",
|
|
||||||
check.Version.Present, check.Version.Null, strconv.Quote(check.Version.Text))
|
|
||||||
if check.Location == nil {
|
|
||||||
_, _ = writer.WriteString(" location: null\n")
|
|
||||||
} else {
|
} else {
|
||||||
_, _ = fmt.Fprintf(writer, " location: {source_id: %s, line: %d, column: %d}\n",
|
check := source.VersionEvidence
|
||||||
strconv.Quote(check.Location.SourceID), check.Location.Line, check.Location.Column)
|
_, _ = fmt.Fprintf(writer, " version_evidence:\n path: %s\n", strconv.Quote(check.Path))
|
||||||
|
_, _ = fmt.Fprintf(writer, " version:\n present: %t\n null: %t\n text: %s\n",
|
||||||
|
check.Version.Present, check.Version.Null, strconv.Quote(check.Version.Text))
|
||||||
|
if check.Location == nil {
|
||||||
|
_, _ = writer.WriteString(" location: null\n")
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprintf(writer, " location: {source_id: %s, line: %d, column: %d}\n",
|
||||||
|
strconv.Quote(check.Location.SourceID), check.Location.Line, check.Location.Column)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(writer, " redacted: %t\n", check.Redacted)
|
||||||
|
}
|
||||||
|
if len(source.EmptyImportSelections) == 0 {
|
||||||
|
_, _ = writer.WriteString(" empty_import_selections: []\n")
|
||||||
|
} else {
|
||||||
|
_, _ = writer.WriteString(" empty_import_selections:\n")
|
||||||
|
for _, selection := range source.EmptyImportSelections {
|
||||||
|
_, _ = fmt.Fprintf(writer, " - path: %s\n location: {source_id: %s, line: %d, column: %d}\n selector: %s\n", strconv.Quote(selection.Path), strconv.Quote(selection.Location.SourceID), selection.Location.Line, selection.Location.Column, strconv.Quote(selection.Selector))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_, _ = fmt.Fprintf(writer, " redacted: %t\n", check.Redacted)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,13 +34,17 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
|||||||
reader := &sourceInventory{values: []configkit.SourceInfo{
|
reader := &sourceInventory{values: []configkit.SourceInfo{
|
||||||
{
|
{
|
||||||
ID: "root", Label: "root source", Path: "root.yml",
|
ID: "root", Label: "root source", Path: "root.yml",
|
||||||
PlacementMode: configkit.PlacementModeMapping,
|
PlacementMode: configkit.PlacementModeMapping, Layer: configkit.LayerMain,
|
||||||
|
EmptyImportSelections: []configkit.EmptyImportSelection{
|
||||||
|
{Path: configkit.Keys("imports", "first"), Location: configkit.Location{SourceID: "root.yml", Line: 3, Column: 4}, Selector: configkit.ImportSelectorGlob},
|
||||||
|
{Path: configkit.Keys("imports", "second"), Location: configkit.Location{}, Selector: configkit.ImportSelectorPath},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "item", Label: "sequence item", Path: "item.yml",
|
ID: "item", Label: "sequence item", Path: "item.yml",
|
||||||
Destination: servicePath, PlacementMode: configkit.PlacementModeSequenceItem,
|
Destination: servicePath, PlacementMode: configkit.PlacementModeSequenceItem,
|
||||||
DestinationUnresolved: true,
|
DestinationIsFallback: true,
|
||||||
VersionCheck: &configkit.SourceVersionCheck{
|
VersionEvidence: &configkit.SourceVersionEvidence{
|
||||||
Path: versionPath,
|
Path: versionPath,
|
||||||
Version: configkit.Version{Present: true, Text: "v2"},
|
Version: configkit.Version{Present: true, Text: "v2"},
|
||||||
Location: &configkit.Location{SourceID: "item.yml", Line: 4, Column: 2},
|
Location: &configkit.Location{SourceID: "item.yml", Line: 4, Column: 2},
|
||||||
@@ -49,7 +53,7 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: "null", Label: "null version", Path: "null.yml",
|
ID: "null", Label: "null version", Path: "null.yml",
|
||||||
Destination: otherPath, PlacementMode: configkit.PlacementModeMapping,
|
Destination: otherPath, PlacementMode: configkit.PlacementModeMapping,
|
||||||
VersionCheck: &configkit.SourceVersionCheck{
|
VersionEvidence: &configkit.SourceVersionEvidence{
|
||||||
Path: nullVersionPath,
|
Path: nullVersionPath,
|
||||||
Version: configkit.Version{Present: true, Null: true},
|
Version: configkit.Version{Present: true, Null: true},
|
||||||
Redacted: true,
|
Redacted: true,
|
||||||
@@ -70,11 +74,13 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
|||||||
var report struct {
|
var report struct {
|
||||||
Scope string `yaml:"scope"`
|
Scope string `yaml:"scope"`
|
||||||
Sources []struct {
|
Sources []struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Destination string `yaml:"destination"`
|
Destination string `yaml:"destination"`
|
||||||
Placement string `yaml:"placement"`
|
Placement string `yaml:"placement"`
|
||||||
DestinationUnresolved bool `yaml:"destination_unresolved"`
|
Layer string `yaml:"layer"`
|
||||||
VersionCheck *struct {
|
EmptyImportSelections []emptyImportSelectionDTO `yaml:"empty_import_selections"`
|
||||||
|
DestinationIsFallback bool `yaml:"destination_is_fallback"`
|
||||||
|
VersionEvidence *struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Version struct {
|
Version struct {
|
||||||
Present bool `yaml:"present"`
|
Present bool `yaml:"present"`
|
||||||
@@ -87,7 +93,7 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
|||||||
Column int `yaml:"column"`
|
Column int `yaml:"column"`
|
||||||
} `yaml:"location"`
|
} `yaml:"location"`
|
||||||
Redacted bool `yaml:"redacted"`
|
Redacted bool `yaml:"redacted"`
|
||||||
} `yaml:"version_check"`
|
} `yaml:"version_evidence"`
|
||||||
} `yaml:"sources"`
|
} `yaml:"sources"`
|
||||||
}
|
}
|
||||||
if err := yaml.Unmarshal(yamlOutput.Bytes(), &report); err != nil {
|
if err := yaml.Unmarshal(yamlOutput.Bytes(), &report); err != nil {
|
||||||
@@ -99,21 +105,31 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
|||||||
if got := []string{report.Sources[0].ID, report.Sources[1].ID, report.Sources[2].ID}; !reflect.DeepEqual(got, []string{"root", "item", "null"}) {
|
if got := []string{report.Sources[0].ID, report.Sources[1].ID, report.Sources[2].ID}; !reflect.DeepEqual(got, []string{"root", "item", "null"}) {
|
||||||
t.Fatalf("source order = %v", got)
|
t.Fatalf("source order = %v", got)
|
||||||
}
|
}
|
||||||
if root := report.Sources[0]; root.Destination != "" || root.Placement != "mapping" || root.DestinationUnresolved || root.VersionCheck != nil {
|
if root := report.Sources[0]; root.Destination != "" || root.Placement != "mapping" || root.DestinationIsFallback || root.VersionEvidence != nil || root.Layer != "main" {
|
||||||
t.Errorf("root source projection = %#v", root)
|
t.Errorf("root source projection = %#v", root)
|
||||||
}
|
}
|
||||||
|
wantSelections := []emptyImportSelectionDTO{
|
||||||
|
{Path: "/imports/first", Location: locationDTO{SourceID: "root.yml", Line: 3, Column: 4}, Selector: "glob"},
|
||||||
|
{Path: "/imports/second", Location: locationDTO{}, Selector: "path"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(report.Sources[0].EmptyImportSelections, wantSelections) || report.Sources[1].EmptyImportSelections == nil || len(report.Sources[1].EmptyImportSelections) != 0 {
|
||||||
|
t.Fatalf("import selections: %#v", report.Sources)
|
||||||
|
}
|
||||||
|
if strings.Contains(yamlOutput.String(), "destination_unresolved:") || strings.Contains(yamlOutput.String(), "version_check:") {
|
||||||
|
t.Fatal("legacy YAML keys present")
|
||||||
|
}
|
||||||
item := report.Sources[1]
|
item := report.Sources[1]
|
||||||
if item.Destination != "/services" || item.Placement != "sequence_item" || !item.DestinationUnresolved {
|
if item.Destination != "/services" || item.Placement != "sequence_item" || !item.DestinationIsFallback {
|
||||||
t.Errorf("sequence item projection = %#v", item)
|
t.Errorf("sequence item projection = %#v", item)
|
||||||
}
|
}
|
||||||
if check := item.VersionCheck; check == nil || check.Path != "/version" || !check.Version.Present || check.Version.Null || check.Version.Text != "v2" || check.Location == nil || check.Location.SourceID != "item.yml" || check.Location.Line != 4 || check.Location.Column != 2 || check.Redacted {
|
if check := item.VersionEvidence; check == nil || check.Path != "/version" || !check.Version.Present || check.Version.Null || check.Version.Text != "v2" || check.Location == nil || check.Location.SourceID != "item.yml" || check.Location.Line != 4 || check.Location.Column != 2 || check.Redacted {
|
||||||
t.Errorf("text version evidence projection = %#v", check)
|
t.Errorf("text version evidence projection = %#v", check)
|
||||||
}
|
}
|
||||||
nullSource := report.Sources[2]
|
nullSource := report.Sources[2]
|
||||||
if nullSource.Destination != "/other" || nullSource.Placement != "mapping" || nullSource.DestinationUnresolved {
|
if nullSource.Destination != "/other" || nullSource.Placement != "mapping" || nullSource.DestinationIsFallback {
|
||||||
t.Errorf("mapping projection = %#v", nullSource)
|
t.Errorf("mapping projection = %#v", nullSource)
|
||||||
}
|
}
|
||||||
if check := nullSource.VersionCheck; check == nil || check.Path != "/metadata/version" || !check.Version.Present || !check.Version.Null || check.Version.Text != "" || check.Location != nil || !check.Redacted {
|
if check := nullSource.VersionEvidence; check == nil || check.Path != "/metadata/version" || !check.Version.Present || !check.Version.Null || check.Version.Text != "" || check.Location != nil || !check.Redacted {
|
||||||
t.Errorf("null/redacted version evidence projection = %#v", check)
|
t.Errorf("null/redacted version evidence projection = %#v", check)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,9 +138,15 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
|||||||
if outcome.Kind != commandkit.OutcomeSuccess || reader.calls != 2 {
|
if outcome.Kind != commandkit.OutcomeSuccess || reader.calls != 2 {
|
||||||
t.Fatalf("text source report outcome=%#v calls=%d", outcome, reader.calls)
|
t.Fatalf("text source report outcome=%#v calls=%d", outcome, reader.calls)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(textOutput.String(), "destination_unresolved:") || strings.Contains(textOutput.String(), "version_check:") {
|
||||||
|
t.Fatal("legacy text keys present")
|
||||||
|
}
|
||||||
|
if strings.Index(textOutput.String(), `path: "/imports/first"`) > strings.Index(textOutput.String(), `path: "/imports/second"`) {
|
||||||
|
t.Fatal("import selection order changed")
|
||||||
|
}
|
||||||
for _, fragment := range []string{
|
for _, fragment := range []string{
|
||||||
`id: "root"`, `destination: ""`, `placement: "mapping"`, "destination_unresolved: false", "version_check: null",
|
`id: "root"`, `destination: ""`, `placement: "mapping"`, `layer: "main"`, "destination_is_fallback: false", "version_evidence: null", `path: "/imports/first"`, `location: {source_id: "root.yml", line: 3, column: 4}`, `selector: "glob"`, `path: "/imports/second"`, `location: {source_id: "", line: 0, column: 0}`, `selector: "path"`, "empty_import_selections: []",
|
||||||
`destination: "/services"`, `placement: "sequence_item"`, "destination_unresolved: true", `path: "/version"`, `text: "v2"`,
|
`destination: "/services"`, `placement: "sequence_item"`, "destination_is_fallback: true", `path: "/version"`, `text: "v2"`,
|
||||||
`destination: "/other"`, `path: "/metadata/version"`, "null: true", `text: ""`, "location: null", "redacted: true",
|
`destination: "/other"`, `path: "/metadata/version"`, "null: true", `text: ""`, "location: null", "redacted: true",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(textOutput.String(), fragment) {
|
if !strings.Contains(textOutput.String(), fragment) {
|
||||||
|
|||||||
@@ -3,51 +3,58 @@ package ckconfig
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format)
|
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||||
if err != nil {
|
record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format)
|
||||||
return configOperationFailure(err, diagnostics, writtenRecord)
|
if err != nil {
|
||||||
}
|
return err
|
||||||
if plan.format == "yaml" && !writtenRecord {
|
|
||||||
if err := writeConfigReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil {
|
|
||||||
return configDeliveryFailure(err, diagnostics, true)
|
|
||||||
}
|
}
|
||||||
}
|
if plan.format == "yaml" && !writtenRecord {
|
||||||
if err := writeConfigReport(invocation.Streams.Out, record); err != nil {
|
if _, err := io.WriteString(writer, "scopes:\n"); err != nil {
|
||||||
return configDeliveryFailure(err, diagnostics, true)
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = writer.Write(record)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if ctxErr := invocation.Context.Err(); ctxErr != nil {
|
||||||
|
return configFailure("Configuration validation was canceled.", acquired.Explanation, ctxErr)
|
||||||
|
}
|
||||||
|
return commandkit.Success()
|
||||||
}
|
}
|
||||||
writtenRecord = true
|
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) {
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ Importing it does not install either command family.
|
|||||||
|
|
||||||
## Module
|
## Module
|
||||||
|
|
||||||
The module path is
|
The module path is `go.maximumdirect.net/ckprompt`. Version v0.2.0 uses
|
||||||
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt`. Its first
|
`go.maximumdirect.net/commandkit v0.3.0`, `go.maximumdirect.net/ckconfig v0.2.0`,
|
||||||
release is prepared as `v0.1.0`. It uses `commandkit`, `ckconfig`, and
|
and PromptKit v0.16.0. See the
|
||||||
PromptKit. See the
|
|
||||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#promptkit-configuration-and-definitions)
|
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#promptkit-configuration-and-definitions)
|
||||||
for callback, input, state, and acquisition contracts.
|
for callback, input, state, and acquisition contracts.
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"go.maximumdirect.net/ckconfig"
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PromptRequest selects one named prompt for inspection.
|
// PromptRequest selects one named prompt for inspection.
|
||||||
@@ -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 {
|
||||||
if len(records) == 0 {
|
left.diagnostics = append(left.diagnostics, right.diagnostics...)
|
||||||
return nil
|
left.explanation.Details = append(left.explanation.Details, right.explanation.Details...)
|
||||||
|
if left.explanation.Message == "" {
|
||||||
|
left.explanation.Message = right.explanation.Message
|
||||||
}
|
}
|
||||||
severity := commandkit.SeverityWarning
|
return left
|
||||||
for _, record := range records {
|
}
|
||||||
if record.Severity == configkit.SeverityError {
|
|
||||||
severity = commandkit.SeverityError
|
func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) promptKitAcquisition {
|
||||||
break
|
if len(records) == 0 {
|
||||||
}
|
return promptKitAcquisition{}
|
||||||
}
|
}
|
||||||
// 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,27 +453,48 @@ 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 {
|
||||||
Write: func(writer io.Writer) error {
|
if record.Severity == configkit.SeverityError {
|
||||||
heading := "Configuration scope " + strconv.Quote(scope)
|
failures = append(failures, record)
|
||||||
if side != "" {
|
} else {
|
||||||
heading += " (" + side + ")"
|
warnings = append(warnings, record)
|
||||||
}
|
}
|
||||||
if _, err := fmt.Fprintf(writer, "%s:\n", heading); err != nil {
|
}
|
||||||
|
messages := promptKitAcquisition{}
|
||||||
|
if len(warnings) > 0 {
|
||||||
|
messages.diagnostics = []commandkit.Diagnostic{{
|
||||||
|
Severity: commandkit.SeverityWarning,
|
||||||
|
Write: func(writer io.Writer) error {
|
||||||
|
return writePromptKitDiagnostics(writer, scope, side, sources, warnings)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if len(failures) > 0 {
|
||||||
|
messages.explanation = commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
||||||
|
return writePromptKitDiagnostics(writer, scope, side, sources, failures)
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePromptKitDiagnostics(writer io.Writer, scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) error {
|
||||||
|
heading := "Configuration scope " + strconv.Quote(scope)
|
||||||
|
if side != "" {
|
||||||
|
heading += " (" + side + ")"
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(writer, "%s:\n", heading); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(sources) > 0 {
|
||||||
|
if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, source := range sources {
|
||||||
|
if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(sources) > 0 {
|
}
|
||||||
if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil {
|
}
|
||||||
return err
|
return configkit.WriteDiagnostics(writer, records)
|
||||||
}
|
|
||||||
for _, source := range sources {
|
|
||||||
if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return configkit.WriteDiagnostics(writer, records)
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"go.maximumdirect.net/ckconfig"
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
const adapterPrompt = `prompts:
|
const adapterPrompt = `prompts:
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"go.maximumdirect.net/ckconfig"
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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) {
|
||||||
if format == "yaml" {
|
report := promptReportFromInspection(inspection)
|
||||||
if err := encodePromptYAML(&output, report); err != nil {
|
if format == "yaml" {
|
||||||
return definitionFailure(err, diagnostics)
|
return encodePromptYAMLBytes(report)
|
||||||
}
|
}
|
||||||
} 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) {
|
||||||
if format == "yaml" {
|
report := profileReportFromInspection(inspection)
|
||||||
if err := encodePromptYAML(&output, report); err != nil {
|
if format == "yaml" {
|
||||||
return definitionFailure(err, diagnostics)
|
return encodePromptYAMLBytes(report)
|
||||||
}
|
}
|
||||||
} 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
|
||||||
|
}
|
||||||
|
_, err = writer.Write(output)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if canceled != nil {
|
||||||
|
return definitionFailure("PromptKit report preparation was canceled.", explanation, canceled)
|
||||||
}
|
}
|
||||||
return writePromptReport(invocation, output.Bytes(), diagnostics)
|
return commandkit.Success()
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatPromptText(report promptInspectionReport) string {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"go.maximumdirect.net/ckconfig"
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
"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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt"
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"go.maximumdirect.net/ckconfig"
|
||||||
|
"go.maximumdirect.net/ckprompt"
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
20
integrations/ckprompt/completion.go
Normal file
20
integrations/ckprompt/completion.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package ckprompt
|
||||||
|
|
||||||
|
import commandkit "go.maximumdirect.net/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,14 +4,15 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt"
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"go.maximumdirect.net/ckconfig"
|
||||||
|
"go.maximumdirect.net/ckprompt"
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type compositionInput struct{ Campaign commandkit.Supplied[string] }
|
type compositionInput struct{ Campaign commandkit.Supplied[string] }
|
||||||
@@ -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,11 @@
|
|||||||
// Package ckprompt provides optional PromptKit configuration comparison,
|
// Package ckprompt, provided by go.maximumdirect.net/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
|
||||||
|
|||||||
@@ -28,16 +28,29 @@ License and notices: [LGPL v3.0](../../LICENSE) |
|
|||||||
|
|
||||||
## ReleaseKit
|
## ReleaseKit
|
||||||
|
|
||||||
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.2` (transitive Go tool requirement; not a runtime import).
|
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.1` (transitive Go tool requirement; 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://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/2d5a8110c61e31daf8698766114ae305d0e0e469/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8).
|
[Source](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/2d5a8110c61e31daf8698766114ae305d0e0e469).
|
||||||
|
|
||||||
|
## ReleaseKit (vanity module)
|
||||||
|
|
||||||
|
Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (transitive Go tool requirement; not a runtime import).
|
||||||
|
|
||||||
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
|
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/LICENSE).
|
||||||
|
|
||||||
|
[Source](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4).
|
||||||
|
|
||||||
## JSON Schema
|
## JSON Schema
|
||||||
|
|
||||||
|
|||||||
36
integrations/ckprompt/docs/releases/v0.2.0.md
Normal file
36
integrations/ckprompt/docs/releases/v0.2.0.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# CommandKit ckprompt v0.2.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
CommandKit ckprompt v0.2.0 moves the PromptKit integration to
|
||||||
|
`go.maximumdirect.net/ckprompt`. It uses core
|
||||||
|
`go.maximumdirect.net/commandkit v0.3.0`,
|
||||||
|
`go.maximumdirect.net/ckconfig v0.2.0`, PromptKit v0.16.0, and ConfigKit v0.8.0.
|
||||||
|
The integration adopts core's invocation reporting and completion contracts
|
||||||
|
while preserving PromptKit's inspection, validation, and smoke semantics.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Replace imports of `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt`
|
||||||
|
with `go.maximumdirect.net/ckprompt`; update core and ckconfig imports to
|
||||||
|
`go.maximumdirect.net/commandkit` and `go.maximumdirect.net/ckconfig`. Select
|
||||||
|
core v0.3.0 and both integration modules at v0.2.0 as a matched set. Old and
|
||||||
|
new core paths identify different Go packages and their types cannot be mixed.
|
||||||
|
|
||||||
|
Handlers use `Invocation.Reporter()` for output and categorized messages;
|
||||||
|
terminal failures carry an explanation and optional cause. The integration
|
||||||
|
submits provider diagnostics through the reporter, respecting shared reporting
|
||||||
|
flags. Replace uses of `Invocation.Streams`, `Outcome.Diagnostics`, and the
|
||||||
|
old error-first failure API as described in the
|
||||||
|
[core migration guide](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/consumers/guide.md#invocation-reporting).
|
||||||
|
PromptKit commands provide completion hints when the application installs the
|
||||||
|
core completion command. Command and flag aliases remain application owned.
|
||||||
|
|
||||||
|
Go 1.26.0 remains the consumer minimum. Importing ckprompt installs no commands;
|
||||||
|
compose configuration scopes and the PromptKit command family explicitly. See
|
||||||
|
the [module README](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.2.0/integrations/ckprompt/README.md)
|
||||||
|
and [consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.2.0/docs/consumers/guide.md#promptkit-configuration-and-definitions).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.2.0/integrations/ckprompt/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.2.0/integrations/ckprompt/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/integrations/ckprompt/v0.2.0/integrations/ckprompt/docs/oss/attribution.md)
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Composed consumer
|
# Composed consumer
|
||||||
|
|
||||||
|
This example uses `go.maximumdirect.net/commandkit v0.3.0`,
|
||||||
|
`go.maximumdirect.net/ckconfig v0.2.0`, and
|
||||||
|
`go.maximumdirect.net/ckprompt v0.2.0`.
|
||||||
|
|
||||||
This example composes application configuration, PromptKit configuration,
|
This example composes application configuration, PromptKit configuration,
|
||||||
PromptKit comparison and catalogs, definition and requirements inspection, and
|
PromptKit comparison and catalogs, definition and requirements inspection, and
|
||||||
one application-owned command:
|
one application-owned command:
|
||||||
@@ -15,8 +19,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 +31,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 +124,8 @@ 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.
|
||||||
|
|
||||||
|
Run `GOWORK=off go test ./...` from this example directory to check it against
|
||||||
|
the module's declared dependencies. A local workspace can instead select the
|
||||||
|
three current sources, as described in the
|
||||||
|
[developer guide](../../../../docs/development.md#documentation-validation).
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt"
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"go.maximumdirect.net/ckconfig"
|
||||||
|
"go.maximumdirect.net/ckprompt"
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type campaign struct {
|
type campaign struct {
|
||||||
@@ -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,15 +3,17 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
|
||||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt"
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"go.maximumdirect.net/ckconfig"
|
||||||
|
"go.maximumdirect.net/ckprompt"
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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,10 +1,8 @@
|
|||||||
module gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt
|
module go.maximumdirect.net/ckprompt
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/CommandKit v0.2.0
|
|
||||||
gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig v0.1.0
|
|
||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0
|
gitea.maximumdirect.net/eric/ConfigKit v0.8.0
|
||||||
gitea.maximumdirect.net/eric/promptkit v0.16.0
|
gitea.maximumdirect.net/eric/promptkit v0.16.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
@@ -15,5 +13,7 @@ require (
|
|||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
github.com/spf13/cobra v1.10.1 // indirect
|
github.com/spf13/cobra v1.10.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
go.maximumdirect.net/ckconfig v0.2.0
|
||||||
|
go.maximumdirect.net/commandkit v0.3.0
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user