Compare commits
161 Commits
integratio
...
main
| 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 |
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
|
||||
.fuse_hidden*
|
||||
|
||||
# Local ReleaseKit bootstrap executable
|
||||
# Local ReleaseKit credentials
|
||||
/.releasekit/
|
||||
|
||||
# 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
|
||||
before changing an application's registry, converts typed inputs before
|
||||
constructing them, and returns outcomes without exiting the process. Its
|
||||
[consumer guide](docs/consumers/guide.md) covers declaration and invocation.
|
||||
[consumer guide](docs/consumers/guide.md) covers declaration, invocation
|
||||
reporting, and migration. Each handler receives one invocation reporter for
|
||||
substantive output, categorized messages, and terminal explanations; `Execute`
|
||||
returns delivery observations with the outcome. The [CLI reference](docs/cli.md)
|
||||
defines the shared visibility policy.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Package | Provides |
|
||||
| --- | --- | --- |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit` | `commandkit` | Typed commands, bindings, execution, outcomes, help, and version. |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | `ckprompt` | PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, concrete request validation, and native smoke tests. |
|
||||
| `go.maximumdirect.net/commandkit` | `commandkit` | Typed commands, bindings, execution reporting and outcomes, help, and version. |
|
||||
| `go.maximumdirect.net/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. |
|
||||
| `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
|
||||
`commandkit.New` or `App.Register` to install it. The integrations depend on the
|
||||
core and their selected upstream libraries; the core does not depend on either
|
||||
integration.
|
||||
Importing an integration does not install commands. The published ckconfig and
|
||||
ckprompt v0.2.0 modules use core v0.3.0. The local
|
||||
ckconfig v0.3.0 candidate uses ConfigKit v1.1.0 and does not compose with
|
||||
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
|
||||
|
||||
- [Core-only example](examples/core/main.go)
|
||||
- [Core-only example](examples/core/README.md)
|
||||
- [ConfigKit example](integrations/ckconfig/examples/simple/README.md)
|
||||
- [Composed ConfigKit and PromptKit example](integrations/ckprompt/examples/composed/README.md)
|
||||
|
||||
@@ -38,7 +43,7 @@ integration.
|
||||
- [Maintainer release procedure](docs/release.md)
|
||||
- [Architecture policy](docs/policy/architecture.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).
|
||||
|
||||
|
||||
35
app.go
35
app.go
@@ -3,7 +3,8 @@ package commandkit
|
||||
import (
|
||||
"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
|
||||
@@ -11,30 +12,50 @@ import (
|
||||
type App struct {
|
||||
identity Identity
|
||||
commands map[string]*commandNode
|
||||
routingRules []RoutingRule
|
||||
aliases *alias.Resolver
|
||||
flagAliases map[string]resolvedFlagAliases
|
||||
discoveryFlags map[string]resolvedFlagAliases
|
||||
snapshot *routingSnapshot
|
||||
}
|
||||
|
||||
type routingSnapshot struct {
|
||||
metadata *commandmeta.Command
|
||||
runners map[commandmeta.Key]commandPayload
|
||||
aliases *alias.Resolver
|
||||
canonical map[string]*canonicalNode
|
||||
commandAliases []commandmeta.CommandAlias
|
||||
flagAliases map[string]resolvedFlagAliases
|
||||
discoveryFlags map[string]resolvedFlagAliases
|
||||
completion bool
|
||||
}
|
||||
|
||||
type commandNode struct {
|
||||
command Command
|
||||
}
|
||||
|
||||
// New creates an application with the supplied identity and initial commands.
|
||||
// New creates an application with the supplied identity and initial canonical
|
||||
// commands and routing rules. Use []Declaration to expand a mixed declaration
|
||||
// collection; []Command cannot be expanded as ...Declaration without
|
||||
// converting its elements.
|
||||
//
|
||||
// The identity name must not be empty. New installs its generated help and
|
||||
// version commands and caller declarations as one batch; an invalid declaration
|
||||
// returns an error without exposing an App. The root help and version names are
|
||||
// reserved for generated commands.
|
||||
func New(identity Identity, commands ...Command) (*App, error) {
|
||||
// version commands and caller declarations as one batch; an invalid, nil, or
|
||||
// zero declaration returns an error without exposing an App. Factory issues
|
||||
// retained in Command and RoutingRule values are reported during installation.
|
||||
// Identical duplicate routing mappings are accepted; conflicting mappings
|
||||
// fail the batch. The root help and version names are reserved for generated
|
||||
// commands.
|
||||
func New(identity Identity, declarations ...Declaration) (*App, error) {
|
||||
if identity.Name == "" {
|
||||
return nil, fmt.Errorf("application identity name must not be empty")
|
||||
}
|
||||
|
||||
app := &App{identity: identity, commands: make(map[string]*commandNode)}
|
||||
all := append(app.builtinCommands(), commands...)
|
||||
builtins := app.builtinCommands()
|
||||
all := make([]Declaration, 0, len(builtins)+len(declarations))
|
||||
all = append(all, builtins...)
|
||||
all = append(all, declarations...)
|
||||
if err := app.installBatch(all...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
118
binding.go
118
binding.go
@@ -1,8 +1,11 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
value := fallback
|
||||
supplied := len(values) > 0
|
||||
for _, raw := range values {
|
||||
for index, raw := range values {
|
||||
parsed, err := parse(raw)
|
||||
if err != nil {
|
||||
return preparedValue[I]{}, err
|
||||
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||
}
|
||||
value = parsed
|
||||
}
|
||||
@@ -47,10 +50,10 @@ func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[boo
|
||||
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||
value := defaultValue
|
||||
supplied := len(values) > 0
|
||||
for _, raw := range values {
|
||||
for index, raw := range values {
|
||||
parsed, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return preparedValue[I]{}, err
|
||||
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||
}
|
||||
value = parsed
|
||||
}
|
||||
@@ -76,10 +79,10 @@ func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, er
|
||||
supplied := len(values) > 0
|
||||
if supplied {
|
||||
parsedValues = make([]V, 0, len(values))
|
||||
for _, raw := range values {
|
||||
for index, raw := range values {
|
||||
parsed, err := parse(raw)
|
||||
if err != nil {
|
||||
return preparedValue[I]{}, err
|
||||
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
|
||||
}
|
||||
parsedValues = append(parsedValues, parsed)
|
||||
}
|
||||
@@ -158,6 +161,28 @@ type preparedInput[I any] struct {
|
||||
values []preparedValue[I]
|
||||
}
|
||||
|
||||
type flagOccurrenceError struct {
|
||||
index int
|
||||
cause error
|
||||
}
|
||||
|
||||
func (err *flagOccurrenceError) Error() string { return err.cause.Error() }
|
||||
|
||||
func (err *flagOccurrenceError) Unwrap() error { return err.cause }
|
||||
|
||||
type flagConversionError struct {
|
||||
binding string
|
||||
spelling string
|
||||
occurrence int
|
||||
cause error
|
||||
}
|
||||
|
||||
func (err *flagConversionError) Error() string {
|
||||
return fmt.Sprintf("flag %s occurrence %d: %v", err.spelling, err.occurrence, err.cause)
|
||||
}
|
||||
|
||||
func (err *flagConversionError) Unwrap() error { return err.cause }
|
||||
|
||||
func (p preparedInput[I]) build() I {
|
||||
var input I
|
||||
if p.newInput != nil {
|
||||
@@ -173,15 +198,36 @@ func (p preparedInput[I]) build() I {
|
||||
// creates an input value. When enforceRequired is false, missing required flags
|
||||
// and operands are allowed so metadata help can validate supplied syntax.
|
||||
func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
|
||||
return prepareInputWithSpellings(spec, flagValues, nil, operands, enforceRequired)
|
||||
}
|
||||
|
||||
func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
|
||||
if len(flagValues) != len(spec.Flags) {
|
||||
return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags))
|
||||
}
|
||||
if flagSpellings == nil {
|
||||
flagSpellings = make([][]string, len(spec.Flags))
|
||||
}
|
||||
if len(flagSpellings) != len(spec.Flags) {
|
||||
return preparedInput[I]{}, fmt.Errorf("received %d flag spelling sets for %d bindings", len(flagSpellings), len(spec.Flags))
|
||||
}
|
||||
preparedFlags := make([]preparedValue[I], len(spec.Flags))
|
||||
missingRequired := ""
|
||||
for i, flag := range spec.Flags {
|
||||
binding := flag.binding
|
||||
value, err := binding.prepare(flagValues[i])
|
||||
if err != nil {
|
||||
var occurrenceErr *flagOccurrenceError
|
||||
if errors.As(err, &occurrenceErr) {
|
||||
spelling := "--" + binding.meta.Name
|
||||
if occurrenceErr.index >= 0 && occurrenceErr.index < len(flagSpellings[i]) && flagSpellings[i][occurrenceErr.index] != "" {
|
||||
spelling = flagSpellings[i][occurrenceErr.index]
|
||||
}
|
||||
return preparedInput[I]{}, &flagConversionError{
|
||||
binding: binding.meta.Name, spelling: spelling,
|
||||
occurrence: occurrenceErr.index + 1, cause: occurrenceErr.cause,
|
||||
}
|
||||
}
|
||||
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
|
||||
}
|
||||
preparedFlags[i] = value
|
||||
@@ -211,60 +257,76 @@ func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []
|
||||
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
|
||||
|
||||
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 {
|
||||
binding := argument.binding
|
||||
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", binding.meta.Name, binding.min, binding.max)
|
||||
if argument.Min < 0 || argument.Max < -1 || (argument.Max >= 0 && argument.Min > argument.Max) {
|
||||
return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", argument.Name, argument.Min, argument.Max)
|
||||
}
|
||||
}
|
||||
if enforceRequired {
|
||||
minimum := 0
|
||||
for _, argument := range definitions {
|
||||
if argument.binding.min > len(operands)-minimum {
|
||||
if argument.Min > total-minimum {
|
||||
return nil, fmt.Errorf("not enough positional arguments")
|
||||
}
|
||||
minimum += argument.binding.min
|
||||
minimum += argument.Min
|
||||
}
|
||||
}
|
||||
|
||||
suffixMinimum := make([]int, len(definitions)+1)
|
||||
for i := len(definitions) - 1; i >= 0; i-- {
|
||||
minimum := definitions[i].binding.min
|
||||
remaining := len(operands) - suffixMinimum[i+1]
|
||||
minimum := definitions[i].Min
|
||||
remaining := total - suffixMinimum[i+1]
|
||||
if minimum > remaining {
|
||||
suffixMinimum[i] = len(operands)
|
||||
suffixMinimum[i] = total
|
||||
} else {
|
||||
suffixMinimum[i] = suffixMinimum[i+1] + minimum
|
||||
}
|
||||
}
|
||||
|
||||
allocated := make([][]string, len(definitions))
|
||||
counts := make([]int, len(definitions))
|
||||
position := 0
|
||||
for i, argument := range definitions {
|
||||
binding := argument.binding
|
||||
remaining := len(operands) - position
|
||||
remaining := total - position
|
||||
available := remaining - suffixMinimum[i+1]
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
count := available
|
||||
if binding.max >= 0 && count > binding.max {
|
||||
count = binding.max
|
||||
if argument.Max >= 0 && count > argument.Max {
|
||||
count = argument.Max
|
||||
}
|
||||
if !enforceRequired && count < binding.min && remaining > 0 {
|
||||
if !enforceRequired && count < argument.Min && remaining > 0 {
|
||||
count = remaining
|
||||
if binding.max >= 0 && count > binding.max {
|
||||
count = binding.max
|
||||
if argument.Max >= 0 && count > argument.Max {
|
||||
count = argument.Max
|
||||
}
|
||||
}
|
||||
if enforceRequired && count < binding.min {
|
||||
return nil, fmt.Errorf("not enough values for argument %q", binding.meta.Name)
|
||||
if enforceRequired && count < argument.Min {
|
||||
return nil, fmt.Errorf("not enough values for argument %q", argument.Name)
|
||||
}
|
||||
allocated[i] = operands[position : position+count]
|
||||
counts[i] = count
|
||||
position += count
|
||||
}
|
||||
if position < len(operands) {
|
||||
return nil, fmt.Errorf("received %d extra positional arguments", len(operands)-position)
|
||||
if position < total {
|
||||
return nil, fmt.Errorf("received %d extra positional arguments", total-position)
|
||||
}
|
||||
return allocated, nil
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
@@ -245,3 +247,90 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
|
||||
t.Fatalf("preparation invoked NewInput %d times", factoryCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteBindsMixedFlagSpellingsWithFreshPresence(t *testing.T) {
|
||||
type input struct {
|
||||
count Supplied[int]
|
||||
enabled Supplied[bool]
|
||||
message Supplied[string]
|
||||
tags Supplied[[]string]
|
||||
required Supplied[string]
|
||||
instance int
|
||||
}
|
||||
type observed struct {
|
||||
count Supplied[int]
|
||||
enabled Supplied[bool]
|
||||
message Supplied[string]
|
||||
tags Supplied[[]string]
|
||||
required Supplied[string]
|
||||
instance int
|
||||
}
|
||||
defaultCount := 9
|
||||
defaultMessage := "fallback"
|
||||
defaultTags := []string{"base"}
|
||||
countParses, inputFactories := 0, 0
|
||||
var calls []observed
|
||||
command := NewCommand(CommandSpec[input]{
|
||||
Name: "run",
|
||||
NewInput: func() input {
|
||||
inputFactories++
|
||||
return input{instance: inputFactories}
|
||||
},
|
||||
Flags: []Flag[input]{
|
||||
ValueFlag(FlagMeta{Name: "count"}, &defaultCount, func(value string) (int, error) {
|
||||
countParses++
|
||||
return strconv.Atoi(value)
|
||||
}, func(target *input, value Supplied[int]) { target.count = value }),
|
||||
BoolFlag(FlagMeta{Name: "enabled"}, true, func(target *input, value Supplied[bool]) { target.enabled = value }),
|
||||
ValueFlag(FlagMeta{Name: "message"}, &defaultMessage, func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[string]) { target.message = value }),
|
||||
RepeatFlag(FlagMeta{Name: "tag"}, defaultTags, func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[[]string]) { target.tags = value }),
|
||||
ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[string]) { target.required = value }),
|
||||
},
|
||||
Run: func(_ Invocation, value input) Outcome {
|
||||
value.tags.Value = append([]string(nil), value.tags.Value...)
|
||||
calls = append(calls, observed(value))
|
||||
return Success()
|
||||
},
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, command,
|
||||
Alias("shortcut", "run"),
|
||||
FlagAliasFor("run", "--number", "--count"),
|
||||
FlagAliasFor("run", "--active", "--enabled"),
|
||||
FlagAliasFor("run", "--text", "--message"),
|
||||
FlagAliasFor("run", "--label", "--tag"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"shortcut", "--number=0", "--count=5", "--active=false", "--enabled=true", "--text=", "--message=last", "--label=a", "--tag=b", "--required=first"},
|
||||
{"run", "--number=0", "--active=false", "--text=", "--label=c", "--required=second"},
|
||||
} {
|
||||
if outcome := app.Execute(context.Background(), args, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
|
||||
t.Fatalf("Execute(%q) = %#v, want success", args, outcome)
|
||||
}
|
||||
}
|
||||
if countParses != 3 {
|
||||
t.Fatalf("count conversions = %d, want every one of three occurrences", countParses)
|
||||
}
|
||||
if len(calls) != 2 || calls[0].count != (Supplied[int]{Value: 5, Set: true}) || calls[0].enabled != (Supplied[bool]{Value: true, Set: true}) || calls[0].message != (Supplied[string]{Value: "last", Set: true}) || !reflect.DeepEqual(calls[0].tags, Supplied[[]string]{Value: []string{"a", "b"}, Set: true}) || calls[0].required != (Supplied[string]{Value: "first", Set: true}) {
|
||||
t.Fatalf("first mixed-spelling invocation = %#v", calls)
|
||||
}
|
||||
if calls[1].count != (Supplied[int]{Value: 0, Set: true}) || calls[1].enabled != (Supplied[bool]{Value: false, Set: true}) || calls[1].message != (Supplied[string]{Value: "", Set: true}) || !reflect.DeepEqual(calls[1].tags, Supplied[[]string]{Value: []string{"c"}, Set: true}) || calls[1].required != (Supplied[string]{Value: "second", Set: true}) || calls[0].instance == calls[1].instance {
|
||||
t.Fatalf("second mixed-spelling invocation = %#v", calls[1])
|
||||
}
|
||||
|
||||
if outcome := app.Execute(context.Background(), []string{"shortcut", "--count=7"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeUsage {
|
||||
t.Fatalf("missing required flag outcome = %#v, want usage", outcome)
|
||||
}
|
||||
if inputFactories != 2 || len(calls) != 2 {
|
||||
t.Fatalf("missing required flag created or ran input: factories=%d calls=%d", inputFactories, len(calls))
|
||||
}
|
||||
if outcome := app.Execute(context.Background(), []string{"shortcut", "--required=defaults"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
|
||||
t.Fatalf("defaulted invocation = %#v, want success", outcome)
|
||||
}
|
||||
if len(calls) != 3 || calls[2].count != (Supplied[int]{Value: 9}) || calls[2].enabled != (Supplied[bool]{Value: true}) || calls[2].message != (Supplied[string]{Value: "fallback"}) || !reflect.DeepEqual(calls[2].tags, Supplied[[]string]{Value: []string{"base"}}) || calls[2].required != (Supplied[string]{Value: "defaults", Set: true}) || calls[2].instance == calls[1].instance {
|
||||
t.Fatalf("omitted values reused prior invocation state: %#v", calls[2])
|
||||
}
|
||||
}
|
||||
|
||||
94
builtins.go
94
builtins.go
@@ -1,18 +1,16 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime/debug"
|
||||
|
||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
const commandKitModulePath = "gitea.maximumdirect.net/eric/CommandKit"
|
||||
const commandKitModulePath = "go.maximumdirect.net/commandkit"
|
||||
|
||||
type helpInput struct {
|
||||
Targets Supplied[[]string]
|
||||
@@ -29,7 +27,7 @@ type versionDocument struct {
|
||||
Revision string `yaml:"revision"`
|
||||
}
|
||||
|
||||
func (a *App) builtinCommands() []Command {
|
||||
func (a *App) builtinCommands() []Declaration {
|
||||
help := captureCommand(CommandSpec[helpInput]{
|
||||
Name: "help",
|
||||
Summary: "Show help for commands.",
|
||||
@@ -47,8 +45,8 @@ func (a *App) builtinCommands() []Command {
|
||||
Summary: "Show application or CommandKit version.",
|
||||
Description: "Report identity and build metadata without loading application configuration.",
|
||||
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: "format", Usage: "output format"}, &formatDefault, parseVersionFormat, func(input *versionInput, value Supplied[string]) { input.Format = 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", 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 {
|
||||
identity := a.identity
|
||||
@@ -58,10 +56,10 @@ func (a *App) builtinCommands() []Command {
|
||||
if identity.Version == "" {
|
||||
identity.Version = "dev"
|
||||
}
|
||||
return renderVersionOutcome(identity, input.Format, invocation.Streams)
|
||||
return renderVersionOutcome(identity, input.Format, invocation.Reporter())
|
||||
},
|
||||
})
|
||||
return []Command{help, version}
|
||||
return []Declaration{help, version}
|
||||
}
|
||||
|
||||
func parseVersionScope(value string) (string, error) {
|
||||
@@ -78,61 +76,41 @@ func parseVersionFormat(value string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (a *App) renderHelpOutcome(path []string, streams Streams) Outcome {
|
||||
session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{})
|
||||
return a.renderHelpOutcomeWithSession(path, streams, session)
|
||||
}
|
||||
|
||||
func (a *App) renderHelpTargets(targets []string, streams Streams) Outcome {
|
||||
session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{})
|
||||
return a.renderHelpTargetsWithSession(targets, streams, session)
|
||||
}
|
||||
|
||||
func (a *App) renderHelpTargetsWithSession(targets []string, streams Streams, session *internalcobra.Session) Outcome {
|
||||
return a.renderHelpOutcomeWithSession(targets, streams, session)
|
||||
}
|
||||
|
||||
func (a *App) renderHelpOutcomeWithSession(path []string, streams Streams, session *internalcobra.Session) Outcome {
|
||||
content, failure := session.RenderHelp(path)
|
||||
func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, reporter *Reporter, session *internalcobra.Session) Outcome {
|
||||
content, failure := session.RenderHelpView(view)
|
||||
if failure != nil {
|
||||
if failure.Usage {
|
||||
return Usage(errors.New(failure.Message))
|
||||
return Usage(failure.Message)
|
||||
}
|
||||
return Failure(errors.New(failure.Message))
|
||||
}
|
||||
if err := writeReport(streams.Out, []byte(content)); err != nil {
|
||||
return incompleteReport(err)
|
||||
return Failure(failure.Message)
|
||||
}
|
||||
_ = reporter.RenderOutput(func(w io.Writer) error {
|
||||
_, err := io.WriteString(w, content)
|
||||
return err
|
||||
})
|
||||
return Success()
|
||||
}
|
||||
|
||||
func renderVersionOutcome(identity Identity, format string, streams Streams) Outcome {
|
||||
var content []byte
|
||||
switch format {
|
||||
case "text":
|
||||
name := identity.Name
|
||||
func renderVersionOutcome(identity Identity, format string, reporter *Reporter) Outcome {
|
||||
if identity.Version == "" {
|
||||
identity.Version = "dev"
|
||||
}
|
||||
line := name + " " + identity.Version
|
||||
switch format {
|
||||
case "text":
|
||||
line := identity.Name + " " + identity.Version
|
||||
if identity.Revision != "" {
|
||||
line += " (" + identity.Revision + ")"
|
||||
}
|
||||
content = []byte(line + "\n")
|
||||
_ = reporter.RenderOutput(func(w io.Writer) error {
|
||||
_, err := io.WriteString(w, line+"\n")
|
||||
return err
|
||||
})
|
||||
case "yaml":
|
||||
if identity.Version == "" {
|
||||
identity.Version = "dev"
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
if err := encodeYAML(&buffer, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision}); err != nil {
|
||||
return Failure(err)
|
||||
}
|
||||
content = buffer.Bytes()
|
||||
_ = reporter.RenderOutput(func(w io.Writer) error {
|
||||
return encodeYAML(w, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision})
|
||||
})
|
||||
default:
|
||||
return Usage(fmt.Errorf("unsupported version format %q", format))
|
||||
}
|
||||
if err := writeReport(streams.Out, content); err != nil {
|
||||
return incompleteReport(err)
|
||||
return Usage(fmt.Sprintf("unsupported version format %q", format))
|
||||
}
|
||||
return Success()
|
||||
}
|
||||
@@ -143,22 +121,6 @@ func encodeYAML(writer io.Writer, document any) error {
|
||||
return encoder.Encode(document)
|
||||
}
|
||||
|
||||
func writeReport(writer io.Writer, content []byte) error {
|
||||
checked := &checkedWriter{writer: writer}
|
||||
_, err := checked.Write(content)
|
||||
if err == nil {
|
||||
err = checked.failure
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func incompleteReport(err error) Outcome {
|
||||
outcome := Failure(err)
|
||||
outcome.ReportIncomplete = true
|
||||
outcome.DeliveryError = err
|
||||
return outcome
|
||||
}
|
||||
|
||||
func readBuildInfo() *debug.BuildInfo {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
|
||||
110
builtins_reporting_test.go
Normal file
110
builtins_reporting_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func builtinReportingTestApp(t *testing.T) *App {
|
||||
t.Helper()
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "Example", Version: "1.2.3"}, command,
|
||||
Group("promptkit", "PromptKit", Group("list", "List", simpleRunnable("collections"))),
|
||||
Alias("r", "run"), Alias("list collections", "promptkit list collections"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func TestHelpAndVersionUseManagedOutput(t *testing.T) {
|
||||
app := builtinReportingTestApp(t)
|
||||
for _, args := range [][]string{{"help", "--quiet"}, {"version", "--quiet"}, {"list", "--help", "--quiet"}} {
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeSuccess || outcome.Status != 0 {
|
||||
t.Errorf("Execute(%q) = %#v", args, outcome)
|
||||
}
|
||||
if output.Len() != 0 || diagnostics.Len() != 0 {
|
||||
t.Errorf("Execute(%q) wrote output %q or diagnostics %q", args, output.String(), diagnostics.String())
|
||||
}
|
||||
if outcome.Presentation.Output.State != DeliverySuppressed {
|
||||
t.Errorf("Execute(%q) output presentation = %#v", args, outcome.Presentation.Output)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{args: []string{"help", "run"}, want: "run"},
|
||||
{args: []string{"help", "r"}, want: "Alias for: Example run"},
|
||||
{args: []string{"list", "--help"}, want: "Reporting Flags"},
|
||||
{args: []string{"version"}, want: "Example 1.2.3"},
|
||||
{args: []string{"version", "--format=yaml"}, want: "version: 1.2.3"},
|
||||
} {
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), test.args, Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeSuccess || !strings.Contains(output.String(), test.want) {
|
||||
t.Errorf("Execute(%q) = %#v, output %q", test.args, outcome, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpAndVersionDestinationFailuresUseReporterObservations(t *testing.T) {
|
||||
app := builtinReportingTestApp(t)
|
||||
writeErr := errors.New("help/version destination failed")
|
||||
for _, args := range [][]string{{"help", "run"}, {"version"}} {
|
||||
var diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, Streams{
|
||||
Out: executeReportingFailureWriter{err: writeErr}, Err: &diagnostics,
|
||||
})
|
||||
if outcome.Kind != OutcomeOperational || outcome.Status != 1 {
|
||||
t.Errorf("Execute(%q) = %#v", args, outcome)
|
||||
}
|
||||
if !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != DeliveryIncomplete {
|
||||
t.Errorf("Execute(%q) presentation = %#v", args, outcome.Presentation)
|
||||
}
|
||||
if !strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output") {
|
||||
t.Errorf("Execute(%q) diagnostics = %q", args, diagnostics.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidHelpAndVersionRequestsRemainUsageOutcomes(t *testing.T) {
|
||||
app := builtinReportingTestApp(t)
|
||||
for _, args := range [][]string{{"help", "unknown"}, {"version", "--scope=unknown"}, {"version", "--format=unknown"}} {
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeUsage || outcome.Status != 2 {
|
||||
t.Errorf("Execute(%q) = %#v", args, outcome)
|
||||
}
|
||||
if !strings.Contains(diagnostics.String(), "Usage: Example") {
|
||||
t.Errorf("Execute(%q) diagnostics = %q", args, diagnostics.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuietBuiltinRenderingSuppressesDestinationWrites(t *testing.T) {
|
||||
app := builtinReportingTestApp(t)
|
||||
|
||||
// A quiet successful run must not touch its supplied result destination.
|
||||
var diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"version", "--quiet"}, Streams{
|
||||
Out: executeReportingFailureWriter{err: io.ErrClosedPipe}, Err: &diagnostics,
|
||||
})
|
||||
if outcome.Kind != OutcomeSuccess || outcome.Presentation.Output.State != DeliverySuppressed {
|
||||
t.Fatalf("quiet version outcome = %#v", outcome)
|
||||
}
|
||||
if diagnostics.Len() != 0 {
|
||||
t.Fatalf("quiet version diagnostics = %q", diagnostics.String())
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ func TestCommandKitBuildIdentityProjectsEffectiveModuleVersion(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
|
||||
199
command.go
199
command.go
@@ -6,15 +6,66 @@ import (
|
||||
"strings"
|
||||
"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,
|
||||
// RootGroup, or an integration. Capture copies declaration slices but retains
|
||||
// callback and caller-owned object references. Installation validates the
|
||||
// declaration before changing an App registry.
|
||||
// Command is an opaque, reusable declaration of canonical commands and groups.
|
||||
// NewCommand, Group, RootGroup, and integration command factories return
|
||||
// Command values. Capture copies declaration slices but retains callback and
|
||||
// caller-owned object references. Installation validates the declaration
|
||||
// before changing an App registry.
|
||||
type Command struct{ record *commandRecord }
|
||||
|
||||
// Declaration is a captured canonical command or application routing rule
|
||||
// accepted by New and App.Register. Use []Declaration when collecting both
|
||||
// kinds. Group and RootGroup children remain Commands; install routing rules
|
||||
// alongside the completed command tree through New or Register. Only
|
||||
// CommandKit declaration types implement Declaration.
|
||||
//
|
||||
// A zero Command, zero RoutingRule, nil declaration, or typed nil declaration
|
||||
// pointer is invalid. Use NoCommand to represent an intentionally absent
|
||||
// optional command.
|
||||
type Declaration interface {
|
||||
commandkitDeclaration()
|
||||
}
|
||||
|
||||
func (Command) commandkitDeclaration() {}
|
||||
|
||||
// RoutingRule is an opaque, reusable command or flag alias declaration. Alias,
|
||||
// FlagAlias, and FlagAliasFor capture routing rules eagerly and retain any
|
||||
// construction issues for New or App.Register to report. Identical duplicate
|
||||
// mappings are accepted; competing targets for the same source are errors.
|
||||
type RoutingRule struct{ record *routingRuleRecord }
|
||||
|
||||
func (RoutingRule) commandkitDeclaration() {}
|
||||
|
||||
type routingRuleKind uint8
|
||||
|
||||
const (
|
||||
commandAliasRule routingRuleKind = iota + 1
|
||||
globalFlagAliasRule
|
||||
scopedFlagAliasRule
|
||||
)
|
||||
|
||||
type capturedFlagSpelling struct {
|
||||
name string
|
||||
short bool
|
||||
}
|
||||
|
||||
type routingRuleRecord struct {
|
||||
kind routingRuleKind
|
||||
sourcePath commandpath.Path
|
||||
targetPath commandpath.Path
|
||||
scopePath commandpath.Path
|
||||
sourceFlag capturedFlagSpelling
|
||||
targetFlag capturedFlagSpelling
|
||||
sourceText string
|
||||
targetText string
|
||||
scopeText string
|
||||
issues []error
|
||||
}
|
||||
|
||||
type commandRecord struct {
|
||||
name string
|
||||
summary string
|
||||
@@ -23,6 +74,7 @@ type commandRecord struct {
|
||||
payload commandPayload
|
||||
issues []error
|
||||
rootOnly bool
|
||||
completion bool
|
||||
absent bool
|
||||
invalid bool
|
||||
}
|
||||
@@ -47,6 +99,8 @@ type FlagMeta struct {
|
||||
Usage string
|
||||
// Required reports whether execution requires the flag to be supplied.
|
||||
Required bool
|
||||
// Completion describes optional value suggestions.
|
||||
Completion CompletionHint
|
||||
}
|
||||
|
||||
// ArgumentMeta describes a positional command argument. Its name and usage are
|
||||
@@ -56,6 +110,8 @@ type ArgumentMeta struct {
|
||||
Name string
|
||||
// Usage briefly describes the argument.
|
||||
Usage string
|
||||
// Completion describes optional value suggestions.
|
||||
Completion CompletionHint
|
||||
}
|
||||
|
||||
// Flag represents a typed binding for a command flag. Copies retain binding
|
||||
@@ -130,7 +186,7 @@ type commandPayload interface {
|
||||
hasRun() bool
|
||||
bridgeFlags() []commandmeta.Flag
|
||||
bridgeArguments() []commandmeta.Argument
|
||||
prepare([][]string, []string, bool) (func(*execution) Outcome, error)
|
||||
prepare([][]string, [][]string, []string, bool) (func(*execution) Outcome, error)
|
||||
}
|
||||
|
||||
type typedCommand[I any] struct {
|
||||
@@ -158,6 +214,7 @@ func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag {
|
||||
Boolean: binding.boolean,
|
||||
Repeated: binding.repeated,
|
||||
Required: binding.meta.Required,
|
||||
Completion: bridgeCompletion(binding.meta.Completion),
|
||||
}
|
||||
}
|
||||
return flags
|
||||
@@ -172,13 +229,14 @@ func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument {
|
||||
Usage: binding.meta.Usage,
|
||||
Min: binding.min,
|
||||
Max: binding.max,
|
||||
Completion: bridgeCompletion(binding.meta.Completion),
|
||||
}
|
||||
}
|
||||
return arguments
|
||||
}
|
||||
|
||||
func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
|
||||
prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired)
|
||||
func (c typedCommand[I]) prepare(flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
|
||||
prepared, err := prepareInputWithSpellings(c.spec, flagValues, flagSpellings, operands, enforceRequired)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -192,13 +250,15 @@ func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enfor
|
||||
if c.privateRun != nil {
|
||||
return c.privateRun(current.commandContext(), input)
|
||||
}
|
||||
return c.spec.Run(Invocation{Context: current.ctx, Streams: current.streams}, input)
|
||||
return c.spec.Run(current.invocation(), input)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewCommand captures a typed command declaration without constructing its
|
||||
// input or running callbacks. Construction issues are retained and reported
|
||||
// when the declaration is installed. The returned declaration can be reused.
|
||||
// NewCommand captures a typed canonical command declaration without
|
||||
// constructing its input or running callbacks. It copies the spec's flags,
|
||||
// arguments, and children slices. Construction issues are retained and
|
||||
// reported when the declaration is installed; NewCommand itself does not
|
||||
// return an error. The returned declaration can be reused across Apps.
|
||||
func NewCommand[I any](spec CommandSpec[I]) Command {
|
||||
return captureCommand(spec, nil)
|
||||
}
|
||||
@@ -220,25 +280,29 @@ func captureCommand[I any](spec CommandSpec[I], privateRun func(commandExecution
|
||||
return Command{record: record}
|
||||
}
|
||||
|
||||
// Group captures a non-runnable command group and a copy of its relative child
|
||||
// slice. Child declarations remain reusable and are not recursively copied.
|
||||
// Group captures a non-runnable canonical command group and a copy of its
|
||||
// relative Command child slice. Child declarations remain reusable and are not
|
||||
// recursively copied. Routing rules cannot be children; install them alongside
|
||||
// the root command tree through New or App.Register.
|
||||
func Group(name, summary string, children ...Command) Command {
|
||||
record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)}
|
||||
captureComposition(record)
|
||||
return Command{record: record}
|
||||
}
|
||||
|
||||
// RootGroup captures a group that may only be installed at the application root.
|
||||
// Installation rejects it below any other command, including through wrappers.
|
||||
// RootGroup captures a group that may only be installed at the application
|
||||
// root. Installation rejects it below any other command, including through
|
||||
// wrappers. Its children are canonical Commands; install routing rules at the
|
||||
// application root separately.
|
||||
func RootGroup(name, summary string, children ...Command) Command {
|
||||
command := Group(name, summary, children...)
|
||||
command.record.rootOnly = true
|
||||
return command
|
||||
}
|
||||
|
||||
// WithError returns a new declaration that also retains a non-nil err for
|
||||
// installation. Earlier declaration and child errors remain available, and
|
||||
// wrapped error identity is preserved. A nil err returns command unchanged.
|
||||
// WithError returns a new Command declaration that also retains a non-nil err
|
||||
// for installation. Earlier declaration and child errors remain available,
|
||||
// and wrapped error identity is preserved. A nil err returns command unchanged.
|
||||
func WithError(command Command, err error) Command {
|
||||
if err == nil {
|
||||
return command
|
||||
@@ -254,11 +318,91 @@ func WithError(command Command, err error) Command {
|
||||
return Command{record: &record}
|
||||
}
|
||||
|
||||
// NoCommand explicitly represents an absent optional declaration. Installation
|
||||
// skips it, but reports errors attached to it; a zero Command is invalid and is
|
||||
// not an absence marker.
|
||||
// NoCommand explicitly represents an absent optional command declaration.
|
||||
// Installation skips it, but reports errors attached to it; a zero Command is
|
||||
// invalid and is not an absence marker. Routing rules have no absence marker.
|
||||
func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
|
||||
|
||||
// Alias captures a command-path alias from path to target. Paths are
|
||||
// whitespace-separated sequences of command names relative to the application
|
||||
// root. The target must resolve to a canonical command or group; aliases do not
|
||||
// add operands or transform flag values. Construction issues are retained for
|
||||
// New or App.Register to report. Identical mappings are accepted, while
|
||||
// different targets for the same source path are installation errors.
|
||||
func Alias(path, target string) RoutingRule {
|
||||
record := &routingRuleRecord{
|
||||
kind: commandAliasRule,
|
||||
sourceText: path,
|
||||
targetText: target,
|
||||
}
|
||||
record.sourcePath = captureRoutingPath("source", path, &record.issues)
|
||||
record.targetPath = captureRoutingPath("target", target, &record.issues)
|
||||
return RoutingRule{record: record}
|
||||
}
|
||||
|
||||
// FlagAlias captures an application-wide flag spelling alias. Spellings must
|
||||
// be one long flag name or one individual short spelling. The mapping applies
|
||||
// wherever its canonical target flag is available and contributes nothing on
|
||||
// commands without that flag. Identical mappings are accepted; different
|
||||
// targets for the same source spelling in this scope are installation errors.
|
||||
func FlagAlias(spelling, target string) RoutingRule {
|
||||
record := &routingRuleRecord{
|
||||
kind: globalFlagAliasRule,
|
||||
sourceText: spelling,
|
||||
targetText: target,
|
||||
}
|
||||
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
|
||||
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
|
||||
return RoutingRule{record: record}
|
||||
}
|
||||
|
||||
// FlagAliasFor captures a flag spelling alias scoped to a canonical command
|
||||
// path, not an alias path. The command and canonical target flag must be
|
||||
// available in the complete installation. Scoped mappings take precedence over
|
||||
// application-wide mappings with the same source spelling. Identical mappings
|
||||
// are accepted; competing targets in the same scope are installation errors.
|
||||
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
|
||||
record := &routingRuleRecord{
|
||||
kind: scopedFlagAliasRule,
|
||||
sourceText: spelling,
|
||||
targetText: target,
|
||||
scopeText: commandPath,
|
||||
}
|
||||
record.scopePath = captureRoutingPath("command scope", commandPath, &record.issues)
|
||||
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
|
||||
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
|
||||
return RoutingRule{record: record}
|
||||
}
|
||||
|
||||
func captureRoutingPath(label, value string, issues *[]error) commandpath.Path {
|
||||
path, err := commandpath.ParseDeclaration(value)
|
||||
if err != nil {
|
||||
*issues = append(*issues, fmt.Errorf("invalid %s path: %w", label, err))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func captureFlagSpelling(label, value string, issues *[]error) capturedFlagSpelling {
|
||||
spelling, err := parseFlagSpelling(value)
|
||||
if err != nil {
|
||||
*issues = append(*issues, fmt.Errorf("invalid %s flag spelling %q: %w", label, value, err))
|
||||
}
|
||||
return spelling
|
||||
}
|
||||
|
||||
func parseFlagSpelling(value string) (capturedFlagSpelling, error) {
|
||||
if strings.HasPrefix(value, "--") {
|
||||
name := value[2:]
|
||||
if validFlagName(name) {
|
||||
return capturedFlagSpelling{name: name}, nil
|
||||
}
|
||||
}
|
||||
if len(value) == 2 && value[0] == '-' && value[1] != '-' {
|
||||
return capturedFlagSpelling{name: value[1:], short: true}, nil
|
||||
}
|
||||
return capturedFlagSpelling{}, fmt.Errorf("must be a long flag or one-byte short flag spelling")
|
||||
}
|
||||
|
||||
func captureComposition(record *commandRecord) {
|
||||
if err := validateCommandName(record.name); err != nil {
|
||||
record.issues = append(record.issues, err)
|
||||
@@ -297,7 +441,7 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
|
||||
}
|
||||
record := command.record
|
||||
path := append(append([]string(nil), parents...), record.name)
|
||||
label := strings.Join(path, " ")
|
||||
label := commandpath.FromTokens(path).String()
|
||||
for _, issue := range record.issues {
|
||||
if label != "" {
|
||||
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
|
||||
@@ -320,11 +464,7 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
|
||||
}
|
||||
|
||||
func validateCommandName(name string) error {
|
||||
fields := strings.Fields(name)
|
||||
if len(fields) != 1 || fields[0] != name || strings.HasPrefix(name, "-") || name == "--" {
|
||||
return fmt.Errorf("command name %q must be one non-option token", name)
|
||||
}
|
||||
return nil
|
||||
return commandpath.ValidateComponent(name)
|
||||
}
|
||||
|
||||
func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
||||
@@ -338,6 +478,9 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
||||
if binding.meta.Name == "help" {
|
||||
return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name)
|
||||
}
|
||||
if commandmeta.IsReportingFlag(binding.meta.Name) {
|
||||
return fmt.Errorf("flag name %q is reserved for reporting", binding.meta.Name)
|
||||
}
|
||||
if _, exists := flagNames[binding.meta.Name]; exists {
|
||||
return fmt.Errorf("duplicate flag name %q", binding.meta.Name)
|
||||
}
|
||||
|
||||
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
|
||||
// command declarations, input bindings, and invocation outcomes. Optional
|
||||
// integration modules provide ConfigKit and PromptKit-specific declarations;
|
||||
// command declarations, application-owned command and flag aliases, input
|
||||
// bindings, invocation-scoped reporting, and outcomes with separate operation
|
||||
// causes and presentation observations. New and App.Register accept canonical
|
||||
// Command declarations and RoutingRule declarations together. Optional
|
||||
// integration modules provide ConfigKit and PromptKit-specific commands;
|
||||
// importing those modules does not install their commands.
|
||||
package commandkit
|
||||
|
||||
68
docs/adr/0013-application-alias-routing.md
Normal file
68
docs/adr/0013-application-alias-routing.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# ADR 0013: Separate application alias routing from canonical command ownership
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Refines [ADR 0011](0011-captured-declarations-and-installation.md)
|
||||
by extending its shared installation boundary to application routing rules.
|
||||
Its canonical command ownership and declaration principles remain in force.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-19
|
||||
|
||||
## Context
|
||||
|
||||
Applications need alternate command paths and flag spellings that suit their
|
||||
operator vocabulary. These conveniences should reuse the behavior supplied by
|
||||
application commands and optional integrations. Canonical command families
|
||||
already establish ownership of handlers, namespaces, inputs, and invocation
|
||||
behavior.
|
||||
|
||||
A routing layer allows applications to choose alternate spellings while
|
||||
preserving those ownership boundaries. Commands and routing rules have distinct
|
||||
roles, and their relationships require validation during application assembly.
|
||||
|
||||
## Decision
|
||||
|
||||
CommandKit core owns alias declaration, resolution, and coordination with parsing
|
||||
and help. Applications choose their alias mappings. Alias infrastructure is
|
||||
available independently of optional integrations under the module boundaries
|
||||
established by [ADR 0010](0010-optional-integration-modules.md).
|
||||
|
||||
Aliases belong to an application routing layer that directs invocations to
|
||||
canonical commands and flags. Canonical definitions retain ownership of their
|
||||
handlers, inputs, and namespaces. An alternate path within an integration's
|
||||
namespace remains an application routing rule; the integration continues to own
|
||||
the canonical command family and its operations.
|
||||
|
||||
Canonical commands and routing rules are distinct declaration kinds installed
|
||||
through a common core assembly boundary. Installation validates their
|
||||
relationships together, preserves canonical definitions and namespace ownership,
|
||||
and applies the batch-atomicity and retained-error model of ADR 0011 to both
|
||||
layers. Declaration capture and reuse follow that existing model.
|
||||
|
||||
An invocation through an alias uses the canonical target's parsing, typed
|
||||
bindings, validation, and ordinary invocation lifecycle. Execution behavior
|
||||
remains with the canonical command and its owning application or integration.
|
||||
Core coordinates alternate routing and presentation through the same command
|
||||
infrastructure.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Alternative | Analysis |
|
||||
| --- | --- |
|
||||
| Install aliases as wrapper commands | Reuses the command tree for discovery, but introduces additional command nodes and handler delegation into namespace ownership and invocation lifetimes. |
|
||||
| Have applications rewrite arguments before dispatch | Gives applications direct control over shortcuts, but repeats routing infrastructure and separates alias handling from target validation, parsing, and help. |
|
||||
| Register aliases through a separate assembly API | Makes routing declarations explicit, but splits validation of commands and their alternate routes across installation boundaries. Distinct declaration kinds express the separation within one atomic batch. |
|
||||
|
||||
## Consequences
|
||||
|
||||
Applications can tailor operator vocabulary while reusing canonical behavior.
|
||||
Core maintains the relationship between routing rules and canonical definitions
|
||||
as declarations are installed. Optional integrations continue to supply their
|
||||
command families through the existing ownership model.
|
||||
|
||||
Routing, parsing, and help must coordinate across the two layers. Exact API
|
||||
shapes, resolution rules, and presentation choices belong in GoDoc and the
|
||||
[CLI reference](../cli.md) when implemented. Those details can evolve within
|
||||
this architectural separation.
|
||||
79
docs/adr/0014-invocation-reporting.md
Normal file
79
docs/adr/0014-invocation-reporting.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# ADR 0014: Share command-line presentation within each invocation
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Extends [ADR 0005](0005-invocation-outcomes.md) with shared reporting
|
||||
for each invocation. Its outcome classifications, standard status meanings,
|
||||
output channels, error-cause preservation, and application ownership of process
|
||||
exit remain in force.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-25
|
||||
|
||||
## Context
|
||||
|
||||
Applications need consistent control over command results, messages shown during
|
||||
a run, and messages shown at its end. Sharing that control reduces repeated
|
||||
output code across applications and integrations.
|
||||
|
||||
An application decides what an error means for its work. It may recover silently,
|
||||
show a warning, or end the invocation. CommandKit needs the chosen outcome and
|
||||
messages to present that decision to the operator.
|
||||
|
||||
## Decision
|
||||
|
||||
CommandKit core provides one reporting facility for each invocation. Application
|
||||
commands, integration commands, and core commands use it for results, immediate
|
||||
messages, and deferred messages. Core applies shared rules for visibility,
|
||||
formatting, and delivery, and completes reporting when the invocation ends.
|
||||
Callers choose the output destinations.
|
||||
|
||||
Applications decide how to respond to domain-specific errors and choose their
|
||||
outcomes and messages. CommandKit may provide shared helpers for carrying,
|
||||
inspecting, and presenting errors while leaving those decisions with the
|
||||
application.
|
||||
|
||||
Applications supply explicit outcomes and explanations for failures. They may
|
||||
also supply a Go error cause. CommandKit keeps that error unchanged for the caller
|
||||
to inspect. Supplying a cause alone does not select an outcome or generate an
|
||||
operator-facing message. CommandKit remains responsible for handling its own
|
||||
errors, including routing, parsing, and presentation failures.
|
||||
|
||||
Integrations adapt upstream results and diagnostics into command outcomes and
|
||||
messages. The upstream libraries retain ownership of their meanings and rules
|
||||
for safe disclosure.
|
||||
|
||||
An Error message explains a failed invocation with a non-zero status. Its
|
||||
explanation is presented at the end of the invocation, after normal reporting
|
||||
finishes. The returned outcome carries that explanation. The application entry
|
||||
point decides how to exit the process.
|
||||
|
||||
CommandKit reports rendering and delivery failures separately from the command's
|
||||
explanation. A presentation failure produces an operational failure for standard
|
||||
outcomes under ADR 0005. Application-defined domain classifications are preserved,
|
||||
along with an already non-zero domain status. A successful domain outcome receives
|
||||
a non-zero status if presentation fails. The original explanation and any supplied
|
||||
cause remain available to the caller. Intentionally suppressed output is not a
|
||||
delivery failure.
|
||||
|
||||
Reporting keeps the state needed to deliver messages and return the invocation
|
||||
outcome. Logging and message history remain application concerns.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Alternative | Analysis |
|
||||
| --- | --- |
|
||||
| Let each application manage all presentation | Gives applications full control, but repeats visibility and delivery code across commands and integrations. |
|
||||
| Have core decide whether domain-specific errors require recovery, a warning, or failure | Reduces some caller code, but gives core decisions that depend on application behavior. |
|
||||
| Use separate facilities for results, immediate messages, and deferred messages | Lets each facility serve one purpose, but spreads visibility and final delivery across several paths. |
|
||||
|
||||
## Consequences
|
||||
|
||||
Applications and integrations share presentation behavior while choosing their
|
||||
own results and messages. Core handles reporting failures consistently and keeps
|
||||
them visible in the returned outcome.
|
||||
|
||||
GoDoc and the [CLI reference](../cli.md) define the implemented API, flags,
|
||||
message formats, and delivery details. Those details can evolve within this
|
||||
ownership model.
|
||||
243
docs/cli.md
243
docs/cli.md
@@ -66,6 +66,130 @@ version: dev
|
||||
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
|
||||
|
||||
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
|
||||
constructing application command inputs.
|
||||
|
||||
## Application-owned aliases
|
||||
|
||||
Applications can install command and flag aliases alongside their canonical
|
||||
commands. Aliases route an invocation to existing behavior; they do not add
|
||||
handlers, commands, or flags to a canonical command family.
|
||||
|
||||
Command alias paths are sequences of command tokens relative to the application
|
||||
root. The executable name, flags, flag values, and operands are not part of an
|
||||
alias path. Matching uses complete tokens and chooses the longest matching
|
||||
path. A group alias preserves its remaining command tokens, and aliases can
|
||||
compose through chains. For example, `list collections` can route to
|
||||
`promptkit list collections`, while `pk` can route to the `promptkit` group.
|
||||
The target must resolve to a canonical command or group.
|
||||
|
||||
Aliases do not rewrite operands or supply arguments. In particular,
|
||||
`promptkit test KIND` remains one canonical command whose `KIND` is an operand;
|
||||
`all` is an operand value, not a child command. `smoke → promptkit test` is a
|
||||
valid shortcut because it preserves the caller's `KIND` value.
|
||||
|
||||
Flag aliases can be application-wide or scoped to a canonical command path.
|
||||
An application-wide alias is effective wherever its canonical target flag is
|
||||
available and is ignored elsewhere. A scoped alias requires its canonical
|
||||
command and target flag to be installed. The selected canonical command
|
||||
determines applicable scoped aliases, so the alias also works when that
|
||||
command is reached through another command path. A scoped mapping takes
|
||||
precedence over an application-wide mapping with the same spelling.
|
||||
|
||||
Only tokens parsed as flag names participate in flag aliasing. Values, operands,
|
||||
and tokens following `--` remain unchanged. This includes values that look like
|
||||
flags, such as `--message=--hlep`. Aliased flags use the target's canonical
|
||||
binding, conversion, default, required-value check, presence, and repetition
|
||||
behavior. Mixed spellings preserve occurrence order: singleton flags use the
|
||||
last successfully parsed occurrence, every supplied occurrence is converted,
|
||||
and repeatable flags accumulate values in order.
|
||||
|
||||
Flag aliases accept one long spelling or one individual short spelling. Short
|
||||
aliases use the canonical short-option cluster and attached-value grammar:
|
||||
|
||||
| Input | Interpretation |
|
||||
| --- | --- |
|
||||
| `-vq` | Two boolean flags, `--verbose` and `--quiet`. |
|
||||
| `-cblue` or `-c blue` | Value `blue` for a value-taking `-c` flag. |
|
||||
| `-vqcblue` | `-v`, `-q`, then `--color=blue`. |
|
||||
| `-cvq` | Value `vq` for a value-taking `-c` flag. |
|
||||
| `-v=false` | Explicit false for the boolean flag `-v`. |
|
||||
| `-c=` | An explicitly supplied empty string. |
|
||||
|
||||
Boolean flags use presence as true; an explicit boolean value requires `=`.
|
||||
The first value-taking short flag consumes the remaining cluster suffix or the
|
||||
next argument. A consumed value is never interpreted as an alias. The canonical
|
||||
short-option empty-value form `-c=` supplies an empty value, consistently with
|
||||
`--color=` and `-c ""`.
|
||||
|
||||
Help reached through an alias describes the canonical command's flags and
|
||||
operands while using the entered route in usage. It identifies the canonical
|
||||
target, lists applicable aliases separately, and supports global help aliases
|
||||
such as `--hlep → --help`. Help for an alias-only prefix lists its routes
|
||||
without creating a command group; the bare incomplete prefix points to that
|
||||
help. Help and alias registration do not construct command inputs or invoke
|
||||
acquisition callbacks.
|
||||
|
||||
Usage diagnostics preserve the command path and flag spelling the caller used.
|
||||
When a command alias resolved successfully, usage identifies its canonical
|
||||
target while retaining the entered path in the diagnostic. An unresolved route
|
||||
receives no invented canonical label. A flag conversion error identifies the
|
||||
entered flag spelling and the failing occurrence while preserving the original
|
||||
cause.
|
||||
|
||||
## Configuration scopes
|
||||
|
||||
An application installs the declaration returned by
|
||||
@@ -155,8 +347,8 @@ interrupt the native value. Existing native markers and leading comments are
|
||||
retained; documents without a marker receive one. Scope labels containing line
|
||||
control characters are quoted to keep them on one comment line. A provider
|
||||
failure leaves the output stream empty. A rendering or output failure may leave
|
||||
an aggregate prefix, and the outcome reports
|
||||
incomplete report delivery.
|
||||
an aggregate prefix; `Outcome.Presentation.Output` records incomplete delivery
|
||||
and the destination or rendering error.
|
||||
|
||||
### List configuration sources
|
||||
|
||||
@@ -171,14 +363,14 @@ sources:
|
||||
- id: source-1
|
||||
label: "application config"
|
||||
path: /etc/example/config.yml
|
||||
layer: base
|
||||
layer: main
|
||||
kind: file
|
||||
underlay_index: 0
|
||||
imported_from: null
|
||||
destination: /server
|
||||
placement: mapping
|
||||
destination_unresolved: false
|
||||
version_check:
|
||||
destination_is_fallback: false
|
||||
version_evidence:
|
||||
path: /version
|
||||
version:
|
||||
present: true
|
||||
@@ -189,22 +381,28 @@ sources:
|
||||
line: 2
|
||||
column: 1
|
||||
redacted: false
|
||||
empty_import_selections: []
|
||||
```
|
||||
|
||||
Each source includes `id`, `label`, `path`, `layer`, `kind`, `underlay_index`,
|
||||
and `imported_from`, plus the native destination pointer, placement, unresolved
|
||||
destination flag, and nullable `version_check`. Placement is `mapping` or
|
||||
`sequence_item`. The import location is null when absent; otherwise it has
|
||||
and `imported_from`, plus the native destination pointer, placement,
|
||||
`destination_is_fallback`, nullable `version_evidence`, and
|
||||
`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`,
|
||||
`null`, `text`, optional source `location`, and `redacted` as returned by
|
||||
ConfigKit. Owning values and source order are preserved, including empty fields,
|
||||
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,
|
||||
quoting labels and paths. These fields project the selected ConfigKit source
|
||||
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)
|
||||
for native evidence definitions.
|
||||
[ConfigKit v1.1.0 source records](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/records.go)
|
||||
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
|
||||
|
||||
@@ -235,7 +433,7 @@ effective:
|
||||
redacted: false
|
||||
yaml: "localhost:8080\n"
|
||||
origin:
|
||||
layer: base
|
||||
layer: main
|
||||
locations:
|
||||
- source_id: source-1
|
||||
line: 4
|
||||
@@ -322,8 +520,9 @@ again.
|
||||
The complete report is prepared before writing. Report preparation failures and
|
||||
cancellation observed before report delivery starts leave CommandKit's report
|
||||
output empty. Once writing starts, CommandKit does not interrupt the supplied
|
||||
writer or roll back delivered bytes. A write error or short write uses the
|
||||
existing incomplete-report delivery outcome and can leave partial output.
|
||||
writer or roll back delivered bytes. A write error or short write is recorded
|
||||
as incomplete delivery in `Outcome.Presentation.Output` and can leave partial
|
||||
output.
|
||||
|
||||
## PromptKit definition inspection
|
||||
|
||||
@@ -539,8 +738,9 @@ execution. See PromptKit's
|
||||
Builder, engine-loading, and native validation errors use the operational path
|
||||
(status `1`) with supported error identity and acquired diagnostics retained.
|
||||
Malformed syntax, missing operands, and unsupported formats use usage status
|
||||
`2`. Successful diagnostics and report-delivery failures follow the common
|
||||
delivery contract.
|
||||
`2`. Successful diagnostics use the common reporting policy. Report delivery
|
||||
failures are recorded in `Outcome.Presentation.Output`, separately from the
|
||||
operation cause.
|
||||
|
||||
### PromptKit catalogs
|
||||
|
||||
@@ -734,8 +934,8 @@ outcome. Operation errors use operational status `1`; aggregate runs continue
|
||||
after individual errors, retain their reports and ordered error text, and join
|
||||
causes for supported `errors.Is`/`errors.As` access. Cancellation stops later
|
||||
operations and preserves results already acquired. Report encoding or delivery
|
||||
failures use the common incomplete-report delivery outcome and retain operation
|
||||
causes where applicable. Syntax, operand, unsupported format, and conflicting
|
||||
failures are recorded in `Outcome.Presentation.Output`, separately from
|
||||
operation causes. Syntax, operand, unsupported format, and conflicting
|
||||
namespace selection are usage status `2`.
|
||||
|
||||
Native configuration, selection, and diagnostic meanings remain owned by
|
||||
@@ -748,4 +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`.
|
||||
Successful operations use status `0`. Application handlers may return a domain
|
||||
classification and status of their choice, which `Execute` preserves. The caller
|
||||
chooses how to map an outcome to process exit.
|
||||
chooses how to map an outcome to process exit. A rendering or delivery failure
|
||||
changes a successful or usage outcome to operational status `1`. A domain
|
||||
outcome keeps its classification and nonzero status; a zero-status domain
|
||||
outcome becomes `1` if presentation fails. The returned `Outcome.Presentation`
|
||||
records output and diagnostic delivery observations separately from its
|
||||
operation `Cause`.
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
# Consumer guide
|
||||
|
||||
CommandKit is distributed as three Go modules. Start with the core and add an
|
||||
integration module only when the application uses that library. Each module
|
||||
returns core `commandkit.Command` declarations, which the application installs
|
||||
through `commandkit.New` or `App.Register`.
|
||||
integration module only when the application uses that library. Core and
|
||||
compatible integration command factories return canonical `commandkit.Command` values.
|
||||
Applications may install those alongside application-owned `RoutingRule`
|
||||
declarations through `commandkit.New` or `App.Register`; routing rules are
|
||||
installed at the application root, not as group children.
|
||||
|
||||
## Module selection
|
||||
|
||||
| Import | Use when |
|
||||
| --- | --- |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit` | The application needs typed command declarations and common CLI behavior. |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
|
||||
| `go.maximumdirect.net/commandkit` | The application needs typed command declarations and common CLI behavior. |
|
||||
| `go.maximumdirect.net/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
|
||||
| `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
|
||||
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
|
||||
|
||||
All three modules declare a consumer minimum of Go 1.26.0. The root module also
|
||||
selects ReleaseKit v0.4.2 as a Go tool, which makes that tool's module graph part
|
||||
of the root consumer module requirements even though production packages do not
|
||||
import ReleaseKit. The [release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
|
||||
defines the support assumption. Maintainers use Go 1.26.8 for validation and
|
||||
Core and ckprompt declare a consumer minimum of Go 1.26.0; the ckconfig
|
||||
v0.3.0 candidate requires Go 1.27.0. The root module also
|
||||
selects ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) as a Go tool, which
|
||||
makes that tool's module graph part of the root consumer module requirements
|
||||
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).
|
||||
|
||||
## Core commands
|
||||
@@ -41,7 +54,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
type GreetInput struct {
|
||||
@@ -62,7 +75,9 @@ func Greeting() commandkit.Command {
|
||||
),
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
||||
_, _ = fmt.Fprintln(invocation.Streams.Out, input.Count.Value)
|
||||
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
|
||||
return commandkit.Failure("Could not write the greeting.").WithCause(err)
|
||||
}
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
@@ -83,6 +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
|
||||
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
|
||||
|
||||
For an application with one ordinary app configuration loader,
|
||||
@@ -216,16 +341,33 @@ application-owned `session restore` command.
|
||||
## Outcomes and ownership
|
||||
|
||||
Core classifies usage and operational failures while preserving application
|
||||
domain outcomes and supported error identity. Report and diagnostic delivery
|
||||
errors remain inspectable separately from the operation cause. The caller owns
|
||||
process startup, signal handling, and exit mapping; `Execute` never exits the
|
||||
process.
|
||||
domain outcomes and supported error identity. Applications choose domain
|
||||
classification and status. A terminal explanation is presented separately from
|
||||
the optional operation cause, while report and diagnostic delivery observations
|
||||
are available in `Outcome.Presentation`. The caller owns process startup,
|
||||
signal handling, and exit mapping; `Execute` never exits the process.
|
||||
|
||||
Go declarations and GoDoc own exact exported contracts. ConfigKit owns
|
||||
configuration decoding, validation, and provenance. PromptKit owns prompt
|
||||
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),
|
||||
[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),
|
||||
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
|
||||
modules. Core owns captured declarations, typed input preparation, installation,
|
||||
dispatch, outcomes, help, and version. `integrations/ckconfig` owns ConfigKit
|
||||
scope adaptation and reports; `integrations/ckprompt` owns PromptKit engine,
|
||||
dispatch, invocation reporting and outcomes, help, and version.
|
||||
`integrations/ckconfig` owns ConfigKit scope adaptation and reports;
|
||||
`integrations/ckprompt` owns PromptKit engine,
|
||||
definition inspection, requirements discovery, and concrete request validation.
|
||||
The maintained [consumer guide](consumers/guide.md) links to the three maintained
|
||||
consumers.
|
||||
@@ -11,6 +12,12 @@ The [README](../README.md) provides project orientation; the
|
||||
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
||||
canonical documentation owners.
|
||||
|
||||
For application-owned aliases, use the
|
||||
[CLI reference](cli.md#application-owned-aliases),
|
||||
[Go declarations and GoDoc](../command.go), and
|
||||
[ADR 0013](adr/0013-application-alias-routing.md). Keep unfinished work and
|
||||
capability proposals in [future work](roadmap/future.md).
|
||||
|
||||
## Initial orientation
|
||||
|
||||
1. Inspect the worktree and preserve unrelated staged and unstaged changes.
|
||||
@@ -47,15 +54,17 @@ canonical documentation owners.
|
||||
| Task | Read |
|
||||
| --- | --- |
|
||||
| Implemented CLI behavior | [CLI reference](cli.md) |
|
||||
| Invocation reporting | [CLI reporting contract](cli.md#reporting), [consumer guide](consumers/guide.md#invocation-reporting), core GoDoc, and [ADR 0014](adr/0014-invocation-reporting.md) |
|
||||
| Application-owned aliases | [CLI reference](cli.md#application-owned-aliases), [Go declarations and GoDoc](../command.go), [consumer guide](consumers/guide.md#application-owned-aliases), and [ADR 0013](adr/0013-application-alias-routing.md) |
|
||||
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [ckconfig package](../integrations/ckconfig/doc.go), [ckprompt package](../integrations/ckprompt/doc.go), and applicable ADRs |
|
||||
| Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md), refined by [ADR 0011](adr/0011-captured-declarations-and-installation.md) |
|
||||
| Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-cobra-backend.md) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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
|
||||
[maintainer release procedure](release.md) owns current release operations.
|
||||
Completed roadmaps and implementation evidence remain available in Git history,
|
||||
including [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
|
||||
including [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
|
||||
[deferred-work record](roadmap/deferred.md) preserves future capability targets
|
||||
and unselected proposals separately from current contracts.
|
||||
[future work record](roadmap/future.md) preserves unfinished work and unselected
|
||||
proposals separately from current contracts.
|
||||
|
||||
## 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
|
||||
under their canonical owners.
|
||||
|
||||
The root module does not traverse nested modules. During development, use a
|
||||
local workspace for all three modules; it is ignored and must not be committed.
|
||||
If a local `go.work` already exists, preserve it and add these module directories:
|
||||
The root module does not traverse nested modules. Preserve the ignored `go.work`
|
||||
and `go.work.sum`; use `GOWORK=off GOTOOLCHAIN=local` for dependency preparation,
|
||||
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
|
||||
[maintainer release procedure](release.md) owns candidate preparation and
|
||||
publication commands.
|
||||
|
||||
## Release tooling development
|
||||
|
||||
The local publication exercise is opt-in and uses disposable Git repositories.
|
||||
Prepare the module cache before running it; ordinary tests do not bootstrap or
|
||||
publish a release:
|
||||
|
||||
```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$'
|
||||
```
|
||||
Run the focused launcher wiring tests with `go test ./scripts`. The release
|
||||
profiles select upstream checks of committed inputs independently for each
|
||||
module; these checks do not replace domain review. See the
|
||||
[release procedure](release.md) for publication, delivery, and recovery.
|
||||
|
||||
Follow the [local commit policy](policy/agents.md#git-commit-policy). Completed
|
||||
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:
|
||||
|
||||
```sh
|
||||
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 && 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 && 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 && 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 && 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 && 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 && 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 && 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 && 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 && go run . session restore session-42 --campaign spring)
|
||||
GOWORK=off GOTOOLCHAIN=local go run ./examples/core workspace greet --count=0 --tag=first --tag second
|
||||
(cd integrations/ckconfig/examples/simple && GOWORK=off GOTOOLCHAIN=local go run . config show)
|
||||
(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 && GOWORK=off GOTOOLCHAIN=local go run . config validate --scope all --campaign spring --limit=0)
|
||||
(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 && 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 && GOWORK=off GOTOOLCHAIN=local go run . promptkit list prompts --config fixtures/before.yml --campaign spring)
|
||||
(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 && GOWORK=off GOTOOLCHAIN=local go run . promptkit list backends --namespace team)
|
||||
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit list namespaces --kind prompt --kind profile)
|
||||
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit list collections)
|
||||
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local go run . promptkit show prompt briefing --campaign spring --format yaml)
|
||||
(cd integrations/ckprompt/examples/composed && GOWORK=off GOTOOLCHAIN=local 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 --var mode=full --format yaml)
|
||||
(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 && 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 && GOWORK=off GOTOOLCHAIN=local go run . promptkit test reachability --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 && GOWORK=off GOTOOLCHAIN=local go run . session restore session-42 --campaign spring)
|
||||
```
|
||||
|
||||
The [consumer guide](consumers/guide.md) explains how their public API usage
|
||||
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;
|
||||
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`
|
||||
|
||||
The root facade owns captured command declarations, binding conversion,
|
||||
invocation coordination, the per-App registry, standard help/version commands,
|
||||
and outcome/diagnostic delivery. Its dependency graph has no ConfigKit or
|
||||
PromptKit integration dependency. Applications provide typed callbacks and
|
||||
choose when to install declarations.
|
||||
The root facade owns captured canonical command declarations, application-owned
|
||||
routing rules, binding conversion, invocation coordination, the per-App
|
||||
registries, standard help/version commands, invocation reporting, and outcome
|
||||
finalization.
|
||||
Its dependency graph has no ConfigKit or PromptKit integration dependency.
|
||||
Applications provide typed callbacks and install routing rules alongside the
|
||||
complete canonical command tree through the same batch API.
|
||||
|
||||
The shared command compiler prepares supplied values before constructing input.
|
||||
The batch installer validates retained issues, placement, and collisions before
|
||||
mutating the registry. Each invocation uses one routing snapshot and backend
|
||||
session; a successful registration invalidates the snapshot for a later
|
||||
invocation. The root package owns app input and callback execution, not provider
|
||||
configuration semantics.
|
||||
Canonical commands own handlers and parsing/help metadata; routing rules resolve
|
||||
entered command paths and flag spellings to those canonical records without
|
||||
adding command nodes or handlers. The shared command compiler prepares supplied
|
||||
values before constructing input. The batch installer validates retained
|
||||
issues, placement, and collisions across both registries before publishing
|
||||
either. Each invocation uses one routing snapshot and backend session; a
|
||||
successful registration invalidates the snapshot for a later invocation. The
|
||||
root package owns app input and callback execution, not provider configuration
|
||||
semantics.
|
||||
|
||||
Each execution creates one reporter backed by the caller's result and diagnostic
|
||||
writers. The Cobra adapter returns parsed values, flag spellings, and reporting
|
||||
flag occurrences. Core reduces those occurrences in entered order after route
|
||||
resolution, applies the final mode and progress selection, and presents any
|
||||
collected parser events through that selection. The selected mode therefore
|
||||
applies to command execution, help, and version; routing and parsing failures
|
||||
remain visible in every mode.
|
||||
|
||||
The reporter owns invocation-scoped visibility, message formatting, managed
|
||||
report rendering, delivery observations, and finalization. It presents queued
|
||||
messages after the handler returns, then a failed outcome's explanation, and
|
||||
closes its handles before `Execute` returns. It records rendering and write
|
||||
errors separately for result and diagnostic destinations; the returned
|
||||
`Outcome.Presentation` exposes those observations independently from the
|
||||
operation cause. The reporter does not retain a transcript and does not close
|
||||
or flush caller-owned writers.
|
||||
|
||||
Internally, one reporting-state mutex guards visibility, queued messages,
|
||||
destination state, and delivery errors while serializing reporter operations
|
||||
and writes. Rendering callbacks execute within that coordination boundary, so
|
||||
they must not recursively submit or render through the same reporter. This is
|
||||
an implementation detail supporting the callback contract; the mutex does not
|
||||
coordinate writes made outside the reporter.
|
||||
|
||||
## `internal/alias` and `internal/commandpath`
|
||||
|
||||
`internal/commandpath` provides shared path parsing and token operations used by
|
||||
declaration capture, routing, and backend selection. `internal/alias` compiles
|
||||
application routing rules, resolves command prefixes while retaining token
|
||||
provenance, and validates command-route termination. Neither package owns
|
||||
handlers, application factories, or backend types.
|
||||
|
||||
## `internal/commandmeta`
|
||||
|
||||
|
||||
@@ -139,16 +139,16 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||
|
||||
## 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
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
illustrative API declarations
|
||||
merely by repeating them in implementation or tests. Cobra/pflag defaults do not
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 1.3
|
||||
|
||||
**Last Updated**: 2026-10-11
|
||||
**Last Updated**: 2026-10-26
|
||||
|
||||
**Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||
|
||||
@@ -75,26 +75,26 @@ Its documentation recommends the same grammar and vocabulary for application-own
|
||||
commands, including those outside its dispatcher, under
|
||||
[ADR 0006](../adr/0006-command-grammar.md).
|
||||
|
||||
CommandKit preserves application-supplied outcome classifications and supported
|
||||
error identity. It assigns responsibility for its diagnostics, avoids duplicate
|
||||
presentation through its own paths, and reports write failures. Caller callbacks
|
||||
retain control of their own output. [ADR 0005](../adr/0005-invocation-outcomes.md)
|
||||
describes the outcome and delivery model.
|
||||
|
||||
Use explicit instance construction and composable command registration, not
|
||||
package-global or import-time registries. Applications and integrations supply
|
||||
the same captured command declarations through the core installation API.
|
||||
captured canonical command declarations through the core installation API.
|
||||
Declaration factories capture metadata, bindings, and callback references eagerly;
|
||||
construction errors remain attached for installation to report. Capture the
|
||||
metadata collections needed to keep later edits to a source declaration from
|
||||
changing a returned command. Application callback objects retain their ownership.
|
||||
One definition supplies routing, parsing, and help metadata.
|
||||
Canonical command declarations own handlers, parsing metadata, and help
|
||||
metadata. Applications may provide separate routing rules in the same
|
||||
installation batch; these rules resolve invocations to canonical commands and
|
||||
do not add command nodes, handlers, or flags.
|
||||
|
||||
Validate each installation batch before mutating the instance registry. A failed
|
||||
construction yields no usable application; a failed later registration leaves the
|
||||
existing registry intact. Separate successful registration calls are not one
|
||||
transaction. Duplicate paths and attempts to replace reserved commands report
|
||||
errors rather than letting registration order select a winner.
|
||||
transaction. Duplicate canonical paths and attempts to replace reserved commands
|
||||
report errors rather than letting registration order select a winner. Identical
|
||||
routing rules are idempotent; competing targets for the same source and scope
|
||||
report an error. Command-scoped flag routing refers to an exact canonical
|
||||
command path.
|
||||
|
||||
Explicit absent-command declarations contribute no commands, help entries, or
|
||||
namespace claims; attached errors are reported before skipping absence. An invalid
|
||||
@@ -121,6 +121,30 @@ inspection/execution loading and focused acquisition callbacks are recommended
|
||||
integration practices. Independent consumer use of other CLI frameworks remains
|
||||
application-owned.
|
||||
|
||||
## Invocation Reporting And Outcomes
|
||||
|
||||
Use one reporting facility per invocation for command results, immediate messages,
|
||||
and deferred messages. Core owns shared visibility, formatting, delivery, and
|
||||
finalization. Applications and integrations choose their report content and
|
||||
messages; callers choose the destinations. [ADR 0014](../adr/0014-invocation-reporting.md)
|
||||
extends the outcome and delivery model in [ADR 0005](../adr/0005-invocation-outcomes.md).
|
||||
|
||||
Applications decide how to respond to domain-specific errors. CommandKit may
|
||||
provide shared helpers for carrying, inspecting, and presenting errors while
|
||||
leaving those decisions with the application. Supplying an error cause alone
|
||||
does not select an outcome or generate an operator-facing message. CommandKit
|
||||
handles its own errors, including routing, parsing, and presentation failures.
|
||||
|
||||
Preserve application-defined domain classifications and supplied error identity.
|
||||
Report rendering and delivery failures separately from the command's explanation
|
||||
and cause, applying the outcome rules in ADR 0005 and ADR 0014. Intentionally
|
||||
suppressed output is not a delivery failure. Present terminal failure explanations
|
||||
after normal reporting finishes, avoid duplicate presentation through core's own
|
||||
paths, and retain the explanation and any supplied cause in the returned outcome.
|
||||
|
||||
Keep reporting state scoped to the invocation's presentation and returned outcome.
|
||||
Logging and message history remain application concerns.
|
||||
|
||||
## Handler Inputs And Invocation State
|
||||
|
||||
Handlers receive typed input structs with explicit flag and operand bindings.
|
||||
@@ -209,8 +233,8 @@ transforming arbitrary application objects.
|
||||
Standard contracts such as `context.Context` and `io.Writer` remain appropriate.
|
||||
Upstream public types may be selected as deliberate dependencies in the
|
||||
integrations that own them; that permission does not justify optional library
|
||||
dependencies in the core. Core diagnostics use a CommandKit-owned severity type,
|
||||
and integrations convert upstream diagnostics while preserving selected outcome
|
||||
dependencies in the core. Core owns shared presentation categories,
|
||||
and integrations adapt upstream diagnostics while preserving selected outcome
|
||||
and error semantics. Application-owned input types remain application-owned.
|
||||
Expose narrow core composition contracts for demonstrated integration needs,
|
||||
without exporting private assembly or backend structures.
|
||||
@@ -270,7 +294,7 @@ recommended for application-owned commands; their names and domain behavior
|
||||
remain application choices.
|
||||
|
||||
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.
|
||||
A replacement backend must preserve published CLI behavior or follow deliberate compatibility
|
||||
changes under the release policy.
|
||||
|
||||
@@ -25,9 +25,11 @@ additional release requirements.
|
||||
CommandKit independently versions three source-only Go modules: the root core,
|
||||
`integrations/ckconfig`, and `integrations/ckprompt`. Standard CLI behavior is
|
||||
exposed by consuming applications; CommandKit does not declare binary
|
||||
deliverables or a cross-platform build matrix. ReleaseKit v0.4.2 is selected as
|
||||
the maintainer publication tool. The [release procedure](../release.md) owns
|
||||
the selected-module workflow, release order, and repository-specific commands;
|
||||
deliverables or a cross-platform build matrix. ReleaseKit v0.9.0 is selected as
|
||||
the maintainer publication tool. Maintainers may separately deliver page-only
|
||||
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
|
||||
import.
|
||||
|
||||
|
||||
145
docs/release.md
145
docs/release.md
@@ -3,10 +3,11 @@
|
||||
## Scope and release order
|
||||
|
||||
CommandKit publishes source-only Go module releases. Each module has an
|
||||
independent version and immutable annotated tag. This procedure publishes no
|
||||
hosted release page, binaries, checksums, or other delivery assets. A successful
|
||||
source-tag push is release completion; CI, module proxy availability, and
|
||||
consumer installation are not completion gates.
|
||||
independent version and immutable annotated tag. A successful source-tag push
|
||||
completes the source release. A separate local delivery command can publish a
|
||||
Gitea release page; there are no binaries,
|
||||
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
|
||||
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.
|
||||
|
||||
Use branch `main`, remote `origin`, and expected effective push endpoint
|
||||
`https://gitea.maximumdirect.net/eric/CommandKit.git`. The selected module profile
|
||||
enforces the endpoint. The maintainer needs the selected
|
||||
`ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git`. The selected
|
||||
module profile enforces the endpoint. The maintainer needs the selected
|
||||
[release toolchain](#release-toolchain), Git push access, and a POSIX shell.
|
||||
This procedure follows the [release policy](policy/release.md); release-note
|
||||
footers follow the [OSS discoverability policy](policy/oss.md#discoverability).
|
||||
|
||||
ReleaseKit v0.4.2 owns candidate validation, publication guards, tag creation,
|
||||
tag publication, and outcome reporting. Its pinned public contracts are the
|
||||
[CLI reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/cli.md),
|
||||
[profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/config.md),
|
||||
and [publication guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/consumers/publish.md).
|
||||
ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) owns candidate validation,
|
||||
publication guards, tag creation, tag publication, and outcome reporting.
|
||||
Its pinned public contracts are the
|
||||
[CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/cli.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
|
||||
bootstraps the native tool into ignored `.releasekit/` storage and delegates to
|
||||
its `publish` command.
|
||||
uses the root module’s registered tool with native host settings and read-only
|
||||
module metadata.
|
||||
|
||||
## Release toolchain
|
||||
|
||||
The selected release toolchain is **Go 1.26.8**. Each module declares a consumer
|
||||
minimum of **Go 1.26.0**. Maintainers use Go 1.26.8 for dependency preparation,
|
||||
candidate validation, and the ReleaseKit bootstrap. The
|
||||
The selected release toolchain is **Go 1.27.0**. Core and ckprompt declare a
|
||||
consumer minimum of **Go 1.26.0**; ckconfig requires **Go 1.27.0**. Maintainers
|
||||
use Go 1.27.0 for dependency preparation, candidate validation, and ReleaseKit
|
||||
execution. The
|
||||
[consumer guide](consumers/guide.md#go-compatibility) and
|
||||
[release policy](policy/release.md#consumer-minimum-and-release-toolchain)
|
||||
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:
|
||||
|
||||
```sh
|
||||
GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.26.8@latest
|
||||
go1.26.8 download
|
||||
export PATH="$(go1.26.8 env GOROOT)/bin:$PATH"
|
||||
GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.27.0@latest
|
||||
go1.27.0 download
|
||||
export PATH="$(go1.27.0 env GOROOT)/bin:$PATH"
|
||||
hash -r
|
||||
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
|
||||
a proportionate summary and compatibility guidance, and ends with immutable
|
||||
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
|
||||
URLs need not resolve before publication.
|
||||
in each URL includes the integration directory where applicable. For future
|
||||
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`,
|
||||
`LICENSE`, and `docs/oss/attribution.md` through `validation.shared_files`.
|
||||
ReleaseKit requires these committed files to exist in the candidate even when
|
||||
no optional file checks are enabled. The list does not enable content checks
|
||||
or expand Go validation scope; see the pinned [profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/b96262eb0ce9c8f2eb3e948a5841db2b799bc91b/docs/config.md).
|
||||
ReleaseKit requires these committed files to exist in the candidate. The list
|
||||
does not expand Go validation scope; see the pinned [profile reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/config.md).
|
||||
|
||||
Each integration also distributes its own `COPYRIGHT`, `LICENSE`, and
|
||||
`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
|
||||
license or content validation.
|
||||
|
||||
The current profiles enable no optional file checks or additional product
|
||||
checks. Publication runs the selected module's standard offline suite and
|
||||
ReleaseKit's candidate/note and publication guards. The profiles select their
|
||||
physical module directory while using the same repository and endpoint.
|
||||
Each profile selects Go formatting, Markdown links, workspace-file and local
|
||||
replacement checks, and forbids the committed `.releasekit/gitea-token` path.
|
||||
These upstream checks inspect committed inputs; module file selection stops at
|
||||
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
|
||||
|
||||
@@ -99,39 +107,92 @@ future sums.
|
||||
|
||||
Then commit the selected module's release note and all intended candidate
|
||||
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
|
||||
module_dir=.
|
||||
module_selector=core
|
||||
module_version=v0.2.0
|
||||
(cd "$module_dir" && GOWORK=off GOTOOLCHAIN=local go mod download)
|
||||
# Commit reviewed dependency changes and the selected module's note on 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
|
||||
offline deterministic default suite once for that module, checks candidate and
|
||||
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
|
||||
pre-publication command. The launcher does not push `main`; publish the
|
||||
only that full tag. `./scripts/releasekit core check vX.Y.Z` is an optional
|
||||
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,
|
||||
proxy availability, or downstream installation.
|
||||
|
||||
## Completion and ambiguous publication
|
||||
|
||||
A successful push to the expected endpoint completes the source release. On an
|
||||
ambiguous result, use the exact endpoint, full tag, and annotated tag object
|
||||
reported by that invocation. Confirm endpoint resolution has not changed, then
|
||||
query only that exact tag ref. Compare the remote annotated tag-object ID with
|
||||
the reported object: an exact match confirms publication, a different object is
|
||||
a conflict, successful output without the ref proves absence at that endpoint,
|
||||
A successful push to the expected endpoint completes the source release.
|
||||
ReleaseKit also attempts to save private publication evidence under the
|
||||
repository's Git common directory, shared by linked worktrees. It reports the
|
||||
saved record path or a recording warning separately from publication status;
|
||||
a storage failure does not change publication status. Records can contain
|
||||
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
|
||||
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
|
||||
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
|
||||
move, overwrite, recreate, or delete a published tag. Resolve conflicts with a
|
||||
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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
type greetInput struct {
|
||||
@@ -14,6 +16,16 @@ type greetInput struct {
|
||||
Tags commandkit.Supplied[[]string]
|
||||
}
|
||||
|
||||
type reportInput struct {
|
||||
Scenario string
|
||||
}
|
||||
|
||||
var (
|
||||
errRecoveredFixture = errors.New("a local read failed and was recovered")
|
||||
errWarningFixture = errors.New("a local warning has a cause")
|
||||
errPartialFixture = errors.New("the local report could not be completed")
|
||||
)
|
||||
|
||||
func newApp() (*commandkit.App, error) {
|
||||
countDefault := 1
|
||||
tagDefaults := []string{"demo"}
|
||||
@@ -29,6 +41,7 @@ func newApp() (*commandkit.App, error) {
|
||||
}),
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
||||
reporter := invocation.Reporter()
|
||||
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
|
||||
if input.Count.Set {
|
||||
count = fmt.Sprintf("explicit %d", input.Count.Value)
|
||||
@@ -37,14 +50,96 @@ func newApp() (*commandkit.App, error) {
|
||||
if input.Tags.Set {
|
||||
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
|
||||
}
|
||||
if _, err := fmt.Fprintf(invocation.Streams.Out, "Hello; count=%s; tags=%s\n", count, tags); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
_ = reporter.RenderOutput(func(writer io.Writer) error {
|
||||
_, err := fmt.Fprintf(writer, "Hello; count=%s; tags=%s; entered=%q; canonical=%q\n", count, tags, invocation.EnteredPath(), invocation.CanonicalPath())
|
||||
return err
|
||||
})
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
report := commandkit.NewCommand(commandkit.CommandSpec[reportInput]{
|
||||
Name: "report",
|
||||
Summary: "Demonstrate invocation reporting with local scenarios.",
|
||||
NewInput: func() reportInput {
|
||||
return reportInput{Scenario: "demo"}
|
||||
},
|
||||
Arguments: []commandkit.Argument[reportInput]{
|
||||
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "scenario", Usage: "demo, recover, warn, warn-error, fail, or partial.", 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)
|
||||
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() {
|
||||
|
||||
199
examples/core/main_test.go
Normal file
199
examples/core/main_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
func TestAliasesReachCoreExampleCommand(t *testing.T) {
|
||||
app, err := newApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
entered string
|
||||
canonical string
|
||||
greeting string
|
||||
}{
|
||||
{name: "command shortcut and scoped flag", args: []string{"hello", "--times=0", "--tag", "one"}, entered: "hello", canonical: "workspace greet", greeting: "count=explicit 0; tags=explicit [one]"},
|
||||
{name: "group shortcut", args: []string{"ws", "greet", "--count=2"}, entered: "ws greet", canonical: "workspace greet", greeting: "count=explicit 2"},
|
||||
{name: "canonical command", args: []string{"workspace", "greet"}, entered: "workspace greet", canonical: "workspace greet", greeting: "count=default 1 (not supplied)"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), test.args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("Execute(%q) = %#v; output %q", test.args, outcome, output.String())
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
test.greeting,
|
||||
"entered=\"" + test.entered + "\"",
|
||||
"canonical=\"" + test.canonical + "\"",
|
||||
} {
|
||||
if !strings.Contains(output.String(), fragment) {
|
||||
t.Errorf("output %q does not contain %q", output.String(), fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalHelpAliasDoesNotRunCoreExampleCommand(t *testing.T) {
|
||||
app, err := newApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"hello", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--hlep → --help") || strings.Contains(output.String(), "Hello;") {
|
||||
t.Fatalf("help alias outcome/output = (%#v, %q)", outcome, output.String())
|
||||
}
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"ws", "--help"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "greet") || strings.Contains(output.String(), "Hello;") {
|
||||
t.Fatalf("group shortcut help outcome/output = (%#v, %q)", outcome, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportingExampleModes(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantOutput []string
|
||||
wantDiag []string
|
||||
omitDiag []string
|
||||
}{
|
||||
{
|
||||
name: "ordinary result",
|
||||
args: []string{"report", "demo"},
|
||||
wantOutput: []string{"Prepared a local report from one fixture."},
|
||||
omitDiag: []string{"Optional fixture warning", "Using deterministic local data.", "Loaded one local fixture.", "DEBUG:"},
|
||||
},
|
||||
{
|
||||
name: "progress selected",
|
||||
args: []string{"report", "demo", "--progress"},
|
||||
wantOutput: []string{"Prepared a local report from one fixture."},
|
||||
wantDiag: []string{"Loaded one local fixture."},
|
||||
omitDiag: []string{"WARN:", "Using deterministic local data.", "DEBUG:"},
|
||||
},
|
||||
{
|
||||
name: "verbose messages",
|
||||
args: []string{"report", "demo", "--verbose"},
|
||||
wantOutput: []string{"Prepared a local report from one fixture."},
|
||||
wantDiag: []string{"WARN: Optional fixture warning", "The sample contains an optional note.", "Using deterministic local data.", "Summary", "Processed one local fixture."},
|
||||
omitDiag: []string{"Loaded one local fixture.", "DEBUG:"},
|
||||
},
|
||||
{
|
||||
name: "debug messages",
|
||||
args: []string{"report", "demo", "--debug"},
|
||||
wantOutput: []string{"Prepared a local report from one fixture."},
|
||||
wantDiag: []string{"WARN: Optional fixture warning", "Using deterministic local data.", "Loaded one local fixture.", "DEBUG: Selected the built-in reporting example.", "Processed one local fixture."},
|
||||
},
|
||||
{
|
||||
name: "quiet suppresses result and messages",
|
||||
args: []string{"report", "demo", "--quiet"},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
outcome, output, diagnostics := executeExample(t, test.args...)
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("Execute(%q) = %#v", test.args, outcome)
|
||||
}
|
||||
for _, fragment := range test.wantOutput {
|
||||
if !strings.Contains(output, fragment) {
|
||||
t.Errorf("output %q does not contain %q", output, fragment)
|
||||
}
|
||||
}
|
||||
for _, fragment := range test.wantDiag {
|
||||
if !strings.Contains(diagnostics, fragment) {
|
||||
t.Errorf("diagnostics %q does not contain %q", diagnostics, fragment)
|
||||
}
|
||||
}
|
||||
for _, fragment := range test.omitDiag {
|
||||
if strings.Contains(diagnostics, fragment) {
|
||||
t.Errorf("diagnostics %q unexpectedly contain %q", diagnostics, fragment)
|
||||
}
|
||||
}
|
||||
if test.name == "quiet suppresses result and messages" && (output != "" || diagnostics != "") {
|
||||
t.Errorf("quiet output/diagnostics = (%q, %q), want both empty", output, diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportingExampleScenarios(t *testing.T) {
|
||||
t.Run("silent recovery retains cause", func(t *testing.T) {
|
||||
outcome, output, diagnostics := executeExample(t, "report", "recover")
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !errors.Is(outcome.Cause, errRecoveredFixture) {
|
||||
t.Fatalf("outcome = %#v, want successful recovery with original cause", outcome)
|
||||
}
|
||||
if output != "" || diagnostics != "" {
|
||||
t.Fatalf("recovery output/diagnostics = (%q, %q), want silent recovery", output, diagnostics)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("warning without cause", func(t *testing.T) {
|
||||
outcome, _, diagnostics := executeExample(t, "report", "warn", "--verbose")
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil {
|
||||
t.Fatalf("outcome = %#v, want success without cause", outcome)
|
||||
}
|
||||
if !strings.Contains(diagnostics, "WARN: The local operation completed with a warning.") {
|
||||
t.Fatalf("diagnostics = %q, want explicit warning", diagnostics)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("warning with independent cause", func(t *testing.T) {
|
||||
outcome, _, diagnostics := executeExample(t, "report", "warn-error", "--verbose")
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !errors.Is(outcome.Cause, errWarningFixture) {
|
||||
t.Fatalf("outcome = %#v, want successful warning with original cause", outcome)
|
||||
}
|
||||
if !strings.Contains(diagnostics, "WARN: The local operation completed with a warning.") || strings.Contains(diagnostics, errWarningFixture.Error()) {
|
||||
t.Fatalf("diagnostics = %q, want warning text without cause text", diagnostics)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("terminal explanation without cause", func(t *testing.T) {
|
||||
outcome, _, diagnostics := executeExample(t, "report", "fail", "--quiet")
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status == 0 || outcome.Cause != nil {
|
||||
t.Fatalf("outcome = %#v, want terminal failure without cause", outcome)
|
||||
}
|
||||
if outcome.Explanation.Message != "The selected local operation could not be completed." || !strings.Contains(diagnostics, outcome.Explanation.Message) {
|
||||
t.Fatalf("explanation/diagnostics = (%q, %q)", outcome.Explanation.Message, diagnostics)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial output keeps cause independent", func(t *testing.T) {
|
||||
outcome, output, diagnostics := executeExample(t, "report", "partial")
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, errPartialFixture) {
|
||||
t.Fatalf("outcome = %#v, want terminal failure with original cause", outcome)
|
||||
}
|
||||
if !strings.Contains(output, "Partial local report: first section complete.") {
|
||||
t.Fatalf("output = %q, want completed first section", output)
|
||||
}
|
||||
if outcome.Explanation.Message != "The local report stopped after partial output." || !strings.Contains(diagnostics, outcome.Explanation.Message) {
|
||||
t.Fatalf("explanation/diagnostics = (%q, %q)", outcome.Explanation.Message, diagnostics)
|
||||
}
|
||||
if strings.Contains(diagnostics, errPartialFixture.Error()) {
|
||||
t.Errorf("diagnostics %q unexpectedly contain cause text", diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func executeExample(t *testing.T, args ...string) (commandkit.Outcome, string, string) {
|
||||
t.Helper()
|
||||
app, err := newApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostics})
|
||||
return outcome, output.String(), diagnostics.String()
|
||||
}
|
||||
367
execute.go
367
execute.go
@@ -5,52 +5,88 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
|
||||
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
|
||||
"go.maximumdirect.net/commandkit/internal/commandpath"
|
||||
)
|
||||
|
||||
// Execute parses and dispatches one invocation. args excludes the executable
|
||||
// name. Supplied values are converted before command input construction; help
|
||||
// does not construct caller inputs or run acquisition callbacks. CommandKit
|
||||
// writes diagnostics to streams.Err and leaves process exit to the caller. A
|
||||
// nil context returns an operational failure, and nil streams are replaced with
|
||||
// io.Discard.
|
||||
// does not construct caller inputs or run acquisition callbacks. It applies the
|
||||
// parsed reporting selection, finalizes invocation presentation, and returns
|
||||
// the resulting Outcome without exiting the process. Supplied streams remain
|
||||
// caller-owned; nil destinations are replaced with io.Discard. A nil context
|
||||
// returns an operational failure.
|
||||
func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome {
|
||||
if ctx == nil {
|
||||
return a.present(Failure(errors.New("invocation context must not be nil")), nil, streams)
|
||||
}
|
||||
streams = normalizeStreams(streams)
|
||||
current := newExecution(a, ctx, streams)
|
||||
routedSnapshot := current.snapshot
|
||||
parsed := current.parse(args)
|
||||
if parsed.Failure != nil {
|
||||
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path)
|
||||
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
|
||||
current.route = resolveEnteredRoute(args, routedSnapshot)
|
||||
current.recordRouteEvents()
|
||||
parsed := current.parse(current.route.args)
|
||||
current.reporter.applySelection(parsed.Reporting)
|
||||
current.recordSelectionEvents(parsed)
|
||||
current.submitDebugEvents()
|
||||
var outcome Outcome
|
||||
path := parsed.Path
|
||||
handlerRan := false
|
||||
if parsed.Failure != nil {
|
||||
outcome = Usage(parsed.Failure.Message)
|
||||
} else if current.route.kind == routeDiscovery {
|
||||
if parsed.HelpRequested {
|
||||
outcome = current.renderHelp(parsed.Path)
|
||||
path = clonePath(current.route.enteredPath)
|
||||
} else {
|
||||
entered := current.route.enteredPath.String()
|
||||
outcome = Usage(fmt.Sprintf("incomplete command path %q; use %q --help", entered, entered))
|
||||
path = clonePath(current.route.enteredPath)
|
||||
}
|
||||
} else {
|
||||
payload, ok := routedSnapshot.runners[parsed.Key]
|
||||
if !ok || payload == nil {
|
||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
||||
outcome = current.renderHelp(parsed.Path)
|
||||
} else if parsed.HelpRequested || !payload.hasRun() {
|
||||
if _, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, false); err != nil {
|
||||
outcome = classifyInputError(err)
|
||||
} else {
|
||||
outcome = current.renderHelp(parsed.Path)
|
||||
}
|
||||
if parsed.HelpRequested || !payload.hasRun() {
|
||||
if _, err := payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
|
||||
return current.present(Usage(err), parsed.Path)
|
||||
}
|
||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
||||
}
|
||||
run, err := payload.prepare(parsed.FlagValues, parsed.Operands, true)
|
||||
} else {
|
||||
run, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.Operands, true)
|
||||
if err != nil {
|
||||
return current.present(Usage(err), parsed.Path)
|
||||
outcome = classifyInputError(err)
|
||||
} else {
|
||||
handlerRan = true
|
||||
outcome = run(current)
|
||||
}
|
||||
return current.present(run(current), parsed.Path)
|
||||
}
|
||||
}
|
||||
return current.finalize(outcome, path, handlerRan)
|
||||
}
|
||||
|
||||
type execution struct {
|
||||
app *App
|
||||
ctx context.Context
|
||||
streams Streams
|
||||
reporter *Reporter
|
||||
debugEvents []string
|
||||
options commandmeta.ParseOptions
|
||||
snapshot *routingSnapshot
|
||||
session *internalcobra.Session
|
||||
route routeResult
|
||||
}
|
||||
|
||||
type commandExecution struct {
|
||||
@@ -60,25 +96,35 @@ type commandExecution struct {
|
||||
|
||||
func (current *execution) commandContext() commandExecution {
|
||||
return commandExecution{
|
||||
invocation: Invocation{Context: current.ctx, Streams: current.streams},
|
||||
invocation: current.invocation(),
|
||||
renderHelpTargetsFunc: current.renderHelpTargets,
|
||||
}
|
||||
}
|
||||
|
||||
func (current *execution) invocation() Invocation {
|
||||
return Invocation{
|
||||
Context: current.ctx,
|
||||
reporter: current.reporter,
|
||||
enteredPath: current.route.enteredPath.String(),
|
||||
canonicalPath: current.route.canonicalPath.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func (current commandExecution) renderHelpTargets(targets []string) Outcome {
|
||||
return current.renderHelpTargetsFunc(targets)
|
||||
}
|
||||
|
||||
func newExecution(app *App, ctx context.Context, streams Streams) *execution {
|
||||
streams = normalizeStreams(streams)
|
||||
current := &execution{
|
||||
app: app,
|
||||
ctx: ctx,
|
||||
streams: streams,
|
||||
reporter: newReporter(streams),
|
||||
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
|
||||
}
|
||||
if app != nil {
|
||||
current.snapshot = app.routeSnapshot()
|
||||
current.session = internalcobra.Prepare(current.snapshot.metadata, current.options)
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -87,7 +133,34 @@ func (current *execution) parse(args []string) commandmeta.Result {
|
||||
if current.app == nil {
|
||||
return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
|
||||
}
|
||||
return current.session.Parse(args)
|
||||
if current.session == nil {
|
||||
current.prepareSession()
|
||||
}
|
||||
return reduceReportingOccurrences(current.session.Parse(args))
|
||||
}
|
||||
|
||||
func (current *execution) prepareSession() {
|
||||
if current.app == nil {
|
||||
return
|
||||
}
|
||||
if current.snapshot == nil {
|
||||
current.snapshot = current.app.routeSnapshot()
|
||||
}
|
||||
options := current.options
|
||||
if current.snapshot != nil {
|
||||
switch current.route.kind {
|
||||
case routeCanonical:
|
||||
options.FlagAliases = current.snapshot.flagAliases[current.route.canonicalPath.Key()].neutralRecords()
|
||||
case routeDiscovery:
|
||||
options.FlagAliases = current.snapshot.discoveryFlags[current.route.discoveryPath.Key()].neutralRecords()
|
||||
default:
|
||||
snapshot := current.snapshot
|
||||
options.FlagAliasesForPath = func(path []string) []commandmeta.FlagAlias {
|
||||
return snapshot.flagAliases[commandpath.FromTokens(path).Key()].neutralRecords()
|
||||
}
|
||||
}
|
||||
current.session = internalcobra.Prepare(current.snapshot.metadata, options)
|
||||
}
|
||||
}
|
||||
|
||||
func (current *execution) presentationSession() *internalcobra.Session {
|
||||
@@ -97,23 +170,50 @@ func (current *execution) presentationSession() *internalcobra.Session {
|
||||
snapshot := current.app.routeSnapshot()
|
||||
if current.session == nil || current.snapshot != snapshot {
|
||||
current.snapshot = snapshot
|
||||
current.session = internalcobra.Prepare(snapshot.metadata, current.options)
|
||||
current.session = nil
|
||||
current.prepareSession()
|
||||
}
|
||||
return current.session
|
||||
}
|
||||
|
||||
func (current *execution) renderHelp(path []string) Outcome {
|
||||
if current.app == nil {
|
||||
return Failure(errors.New("cannot render help on a nil app"))
|
||||
err := errors.New("cannot render help on a nil app")
|
||||
return Failure(err.Error()).WithCause(err)
|
||||
}
|
||||
return current.app.renderHelpOutcomeWithSession(path, current.streams, current.presentationSession())
|
||||
session := current.presentationSession()
|
||||
view := current.app.helpView(current.route, current.snapshot)
|
||||
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
||||
}
|
||||
|
||||
func (current *execution) renderHelpTargets(targets []string) Outcome {
|
||||
if current.app == nil {
|
||||
return Failure(errors.New("cannot render help on a nil app"))
|
||||
err := errors.New("cannot render help on a nil app")
|
||||
return Failure(err.Error()).WithCause(err)
|
||||
}
|
||||
return current.app.renderHelpTargetsWithSession(targets, current.streams, current.presentationSession())
|
||||
snapshot := current.app.routeSnapshot()
|
||||
if snapshot == nil {
|
||||
err := errors.New("cannot render help without command metadata")
|
||||
return Failure(err.Error()).WithCause(err)
|
||||
}
|
||||
route := resolveEnteredRoute(targets, snapshot)
|
||||
// Explicit help targets are paths, not command invocations with operands.
|
||||
// Discovery routes have no canonical path and retain only trailing options.
|
||||
if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) {
|
||||
session := internalcobra.Prepare(snapshot.metadata, current.options)
|
||||
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
|
||||
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
||||
}
|
||||
options := current.options
|
||||
switch route.kind {
|
||||
case routeCanonical:
|
||||
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
|
||||
case routeDiscovery:
|
||||
options.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords()
|
||||
}
|
||||
session := internalcobra.Prepare(snapshot.metadata, options)
|
||||
view := current.app.helpView(route, snapshot)
|
||||
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
|
||||
}
|
||||
|
||||
func (current *execution) usageLine(path []string) string {
|
||||
@@ -129,71 +229,133 @@ func (current *execution) usageLine(path []string) string {
|
||||
return current.app.usageFallback()
|
||||
}
|
||||
|
||||
func (current *execution) present(outcome Outcome, path []string) Outcome {
|
||||
return current.app.presentWithUsageLine(outcome, path, current.streams, current.usageLine)
|
||||
func (current *execution) routeUsageLine() string {
|
||||
line := current.usageLine(clonePath(current.route.canonicalPath))
|
||||
canonical := strings.TrimSpace(current.app.identity.Name + " " + current.route.canonicalPath.String())
|
||||
entered := strings.TrimSpace(current.app.identity.Name + " " + current.route.enteredPath.String())
|
||||
line = strings.Replace(line, "Usage: "+canonical, "Usage: "+entered, 1)
|
||||
return line + "\nAlias for: " + canonical
|
||||
}
|
||||
|
||||
func normalizeStreams(streams Streams) Streams {
|
||||
if streams.Out == nil {
|
||||
streams.Out = io.Discard
|
||||
func (current *execution) recordRouteEvents() {
|
||||
entered := current.route.enteredPath.String()
|
||||
canonical := current.route.canonicalPath.String()
|
||||
switch current.route.kind {
|
||||
case routeCanonical:
|
||||
if entered == canonical {
|
||||
if canonical == "" {
|
||||
current.debugEvents = append(current.debugEvents, "selected the application root command")
|
||||
} else {
|
||||
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected command path %q", canonical))
|
||||
}
|
||||
} else {
|
||||
current.debugEvents = append(current.debugEvents, fmt.Sprintf("resolved command alias %q to %q", entered, canonical))
|
||||
}
|
||||
case routeDiscovery:
|
||||
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected discovery route for prefix %q", entered))
|
||||
default:
|
||||
if entered != "" {
|
||||
current.debugEvents = append(current.debugEvents, fmt.Sprintf("no canonical route matched command path %q", entered))
|
||||
}
|
||||
if streams.Err == nil {
|
||||
streams.Err = io.Discard
|
||||
}
|
||||
return streams
|
||||
}
|
||||
|
||||
func (a *App) present(outcome Outcome, path []string, streams Streams) Outcome {
|
||||
return a.presentWithUsageLine(outcome, path, streams, a.usageLine)
|
||||
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"
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
delivered = true
|
||||
writer := &checkedWriter{writer: streams.Err}
|
||||
writeErr := diagnostic.Write(writer)
|
||||
if writer.failure != nil {
|
||||
if writeErr == nil {
|
||||
writeErr = writer.failure
|
||||
} else if !errors.Is(writeErr, writer.failure) {
|
||||
writeErr = errors.Join(writeErr, writer.failure)
|
||||
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
|
||||
}
|
||||
}
|
||||
if writeErr != nil {
|
||||
return deliveryFailure(outcome, writeErr)
|
||||
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 delivered {
|
||||
outcome.DiagnosticDelivery = DeliveryComplete
|
||||
current.debugEvents = append(current.debugEvents, event)
|
||||
}
|
||||
}
|
||||
|
||||
func (current *execution) submitDebugEvents() {
|
||||
events := current.debugEvents
|
||||
current.debugEvents = nil
|
||||
for _, event := range events {
|
||||
_ = current.reporter.Debug(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (current *execution) finalize(outcome Outcome, path []string, handlerRan bool) Outcome {
|
||||
if outcome.Kind == OutcomeUsage {
|
||||
details := append([]Detail(nil), outcome.Explanation.Details...)
|
||||
details = append(details, func(w io.Writer) error {
|
||||
line := current.usageText(path)
|
||||
if !strings.HasSuffix(line, "\n") {
|
||||
line += "\n"
|
||||
}
|
||||
_, err := io.WriteString(w, line)
|
||||
return err
|
||||
})
|
||||
outcome.Explanation.Details = details
|
||||
}
|
||||
var completion func(*Reporter)
|
||||
if handlerRan {
|
||||
completion = func(reporter *Reporter) {
|
||||
kind := outcome.Kind
|
||||
if kind == "" && outcome.Status == 0 && outcome.Domain == "" {
|
||||
kind = OutcomeSuccess
|
||||
}
|
||||
path := current.route.canonicalPath.String()
|
||||
_ = reporter.Debug(fmt.Sprintf("handler completed for %q with %s outcome and status %d", path, kind, outcome.Status))
|
||||
}
|
||||
}
|
||||
return current.reporter.finalize(outcome, completion)
|
||||
}
|
||||
|
||||
func (current *execution) usageText(path []string) string {
|
||||
switch {
|
||||
case current.route.kind == routeDiscovery && current.app != nil:
|
||||
return fmt.Sprintf("Usage: %s %s [command]", current.app.identity.Name, current.route.enteredPath.String())
|
||||
case current.route.kind == routeCanonical && !current.route.enteredPath.Equal(current.route.canonicalPath):
|
||||
return current.routeUsageLine()
|
||||
default:
|
||||
return current.usageLine(path)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
func (a *App) usageLine(path []string) string {
|
||||
@@ -212,42 +374,3 @@ func (a *App) usageFallback() string {
|
||||
}
|
||||
return fmt.Sprintf("Usage: %s [command]", name)
|
||||
}
|
||||
|
||||
func hasErrorDiagnostic(diagnostics []Diagnostic) bool {
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Write != nil && diagnostic.Severity == SeverityError {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func deliveryFailure(outcome Outcome, err error) Outcome {
|
||||
if outcome.DeliveryError == nil {
|
||||
outcome.DeliveryError = err
|
||||
} else {
|
||||
outcome.DeliveryError = errors.Join(outcome.DeliveryError, err)
|
||||
}
|
||||
outcome.DiagnosticDelivery = DeliveryIncomplete
|
||||
if outcome.Kind != OutcomeDomain {
|
||||
outcome.Kind = OutcomeOperational
|
||||
outcome.Status = 1
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
type checkedWriter struct {
|
||||
writer io.Writer
|
||||
failure error
|
||||
}
|
||||
|
||||
func (w *checkedWriter) Write(data []byte) (int, error) {
|
||||
n, err := w.writer.Write(data)
|
||||
if err == nil && n < len(data) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil && w.failure == nil {
|
||||
w.failure = err
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
@@ -19,7 +19,7 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
}
|
||||
var inputs []input
|
||||
var contexts []context.Context
|
||||
var streams []commandkit.Streams
|
||||
var reporters []*commandkit.Reporter
|
||||
firstContext := context.WithValue(context.Background(), struct{}{}, "first")
|
||||
secondContext := context.WithValue(context.Background(), struct{}{}, "second")
|
||||
var firstOut, secondOut, firstDiagnostic, secondDiagnostic bytes.Buffer
|
||||
@@ -32,7 +32,12 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome {
|
||||
inputs = append(inputs, value)
|
||||
contexts = append(contexts, invocation.Context)
|
||||
streams = append(streams, invocation.Streams)
|
||||
reporter := invocation.Reporter()
|
||||
reporters = append(reporters, reporter)
|
||||
_ = reporter.RenderOutput(func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, value.name.Value+"\n")
|
||||
return err
|
||||
})
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
@@ -52,11 +57,11 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
if inputs[1].name.Set || inputs[1].name.Value != "" || inputs[1].ids.Set {
|
||||
t.Fatalf("second invocation inherited state: %#v", inputs[1])
|
||||
}
|
||||
if streams[2].Out != io.Discard || streams[2].Err != io.Discard {
|
||||
t.Fatalf("nil streams were not replaced with io.Discard: %#v", streams[2])
|
||||
if reporters[0] == nil || reporters[1] == nil || reporters[2] == nil || reporters[0] == reporters[1] || reporters[0] == reporters[2] || reporters[1] == reporters[2] {
|
||||
t.Fatalf("invocations did not receive fresh reporters: %#v", reporters)
|
||||
}
|
||||
if contexts[0] != firstContext || contexts[1] != secondContext || streams[0].Out != &firstOut || streams[0].Err != &firstDiagnostic || streams[1].Out != &secondOut || streams[1].Err != &secondDiagnostic {
|
||||
t.Fatalf("handler invocation contexts or streams were not forwarded independently")
|
||||
if firstOut.String() != "first\n" || secondOut.String() != "\n" || contexts[0] != firstContext || contexts[1] != secondContext {
|
||||
t.Fatalf("handler invocation contexts or reporter destinations were not independent: out=%q/%q contexts=%v/%v", firstOut.String(), secondOut.String(), contexts[0], contexts[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +70,7 @@ func TestOmittedInputFactoryUsesExactPointerZeroValue(t *testing.T) {
|
||||
Name: "run",
|
||||
Run: func(_ commandkit.Invocation, value *int) commandkit.Outcome {
|
||||
if value != nil {
|
||||
return commandkit.Failure(errors.New("omitted input factory allocated a pointer"))
|
||||
return commandkit.Failure("omitted input factory allocated a pointer")
|
||||
}
|
||||
return commandkit.Success()
|
||||
},
|
||||
@@ -136,9 +141,9 @@ func TestExecutePresentsHandlerUsageAfterRegisteringCommand(t *testing.T) {
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
calls++
|
||||
if err := app.Register(late); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
return commandkit.Failure(err.Error()).WithCause(err)
|
||||
}
|
||||
return commandkit.Usage(usageCause)
|
||||
return commandkit.Usage("The handler requests usage.").WithCause(usageCause)
|
||||
},
|
||||
})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
@@ -195,12 +200,11 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
|
||||
|
||||
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
domainCause := errors.New("domain rejected the request")
|
||||
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "review needed")
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
outcome := commandkit.Domain("sample", 2, domainCause)
|
||||
outcome.Diagnostics = []commandkit.Diagnostic{warning}
|
||||
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
|
||||
_ = invocation.Reporter().Warn("review needed")
|
||||
outcome := commandkit.Domain("sample", 2).WithExplanation("The request was rejected.").WithCause(domainCause)
|
||||
return outcome
|
||||
},
|
||||
})
|
||||
@@ -209,19 +213,18 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||
outcome := app.Execute(context.Background(), []string{"run", "--verbose"}, commandkit.Streams{Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeDomain || outcome.Domain != "sample" || outcome.Status != 2 || !errors.Is(outcome.Cause, domainCause) {
|
||||
t.Fatalf("domain outcome changed: %#v", outcome)
|
||||
}
|
||||
if diagnostic.String() != "review needed\ndomain rejected the request\n" || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
||||
if diagnostic.String() != "WARN: review needed\nERROR: The request was rejected.\n" || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
|
||||
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
|
||||
}
|
||||
warningOnly := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "warn",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
result := commandkit.Success()
|
||||
result.Diagnostics = []commandkit.Diagnostic{warning}
|
||||
return result
|
||||
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
|
||||
_ = invocation.Reporter().Warn("review needed")
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
|
||||
@@ -229,23 +232,18 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diagnostic.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"warn"}, commandkit.Streams{Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "review needed\n" {
|
||||
outcome = app.Execute(context.Background(), []string{"warn", "--verbose"}, commandkit.Streams{Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "WARN: review needed\n" {
|
||||
t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T) {
|
||||
cause := errors.New("already shown")
|
||||
func TestExecuteDoesNotPresentOperationCauseWithoutExplanation(t *testing.T) {
|
||||
cause := errors.New("private operation detail")
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
outcome := commandkit.Domain("sample", 2, cause)
|
||||
outcome.Diagnostics = []commandkit.Diagnostic{{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "presented\n")
|
||||
return err
|
||||
}}}
|
||||
return outcome
|
||||
return commandkit.Domain("sample", 2).WithCause(cause)
|
||||
},
|
||||
})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
@@ -254,21 +252,11 @@ func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T)
|
||||
}
|
||||
var diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeDomain || diagnostic.String() != "presented\n" {
|
||||
t.Fatalf("presented cause was duplicated: %#v, %q", outcome, diagnostic.String())
|
||||
if outcome.Kind != commandkit.OutcomeDomain || !errors.Is(outcome.Cause, cause) {
|
||||
t.Fatalf("domain outcome changed: %#v", outcome)
|
||||
}
|
||||
|
||||
diagnostic.Reset()
|
||||
completed := commandkit.Domain("sample", 2, cause)
|
||||
completed.DiagnosticDelivery = commandkit.DeliveryComplete
|
||||
command = commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "done", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return completed }})
|
||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcome = app.Execute(context.Background(), []string{"done"}, commandkit.Streams{Err: &diagnostic})
|
||||
if diagnostic.Len() != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
||||
t.Fatalf("completed diagnostics were repeated: %#v, %q", outcome, diagnostic.String())
|
||||
if strings.Contains(diagnostic.String(), cause.Error()) || !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
|
||||
t.Fatalf("cause was presented instead of the fallback explanation: %#v, %q", outcome, diagnostic.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,9 +266,7 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(operationFailure)
|
||||
outcome.ReportIncomplete = true
|
||||
return outcome
|
||||
return commandkit.Failure("The operation was canceled.").WithCause(operationFailure)
|
||||
},
|
||||
})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
@@ -288,12 +274,12 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{err: writeFailure}})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.DeliveryError, writeFailure) {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeFailure) {
|
||||
t.Fatalf("combined operation and delivery failure = %#v", outcome)
|
||||
}
|
||||
|
||||
short := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: shortWriter{}})
|
||||
if !errors.Is(short.DeliveryError, io.ErrShortWrite) || short.DiagnosticDelivery != commandkit.DeliveryIncomplete {
|
||||
if !errors.Is(short.Presentation.Diagnostics.WriteError, io.ErrShortWrite) || short.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("short write was not detected: %#v", short)
|
||||
}
|
||||
}
|
||||
@@ -339,7 +325,7 @@ func TestExecuteRunsApplicationOwnedNestedHelp(t *testing.T) {
|
||||
)},
|
||||
Run: func(_ commandkit.Invocation, value input) commandkit.Outcome {
|
||||
received = append(received, value)
|
||||
return commandkit.Domain("session-help", 7, domainCause)
|
||||
return commandkit.Domain("session-help", 7).WithExplanation("Session help is unavailable.").WithCause(domainCause)
|
||||
},
|
||||
})
|
||||
session := commandkit.Group("session", "Session commands.", help)
|
||||
@@ -463,7 +449,7 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
|
||||
t.Fatalf("version invoked an application command: %#v calls=%d/%d", versionOutcome, factoryCalls, runCalls)
|
||||
}
|
||||
failed := app.Execute(context.Background(), []string{"config", "show", "--help"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic})
|
||||
if failed.Kind != commandkit.OutcomeOperational || !failed.ReportIncomplete || !errors.Is(failed.DeliveryError, io.ErrShortWrite) {
|
||||
if failed.Kind != commandkit.OutcomeOperational || failed.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(failed.Presentation.Output.WriteError, io.ErrShortWrite) {
|
||||
t.Fatalf("failed help delivery = %#v", failed)
|
||||
}
|
||||
out.Reset()
|
||||
@@ -503,7 +489,7 @@ func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) {
|
||||
t.Fatalf("invalid version scope = %#v, output %q", invalid, out.String())
|
||||
}
|
||||
failed := app.Execute(context.Background(), []string{"version"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic})
|
||||
if failed.Kind != commandkit.OutcomeOperational || !failed.ReportIncomplete || !errors.Is(failed.DeliveryError, io.ErrShortWrite) {
|
||||
if failed.Kind != commandkit.OutcomeOperational || failed.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(failed.Presentation.Output.WriteError, io.ErrShortWrite) {
|
||||
t.Fatalf("failed version delivery = %#v", failed)
|
||||
}
|
||||
}
|
||||
@@ -532,11 +518,11 @@ func TestHelpEntryFormsPresentReportAndDiagnosticFailures(t *testing.T) {
|
||||
for _, args := range [][]string{nil, {"notes"}, {"--help"}, {"-h"}, {"notes", "--help"}, {"help"}, {"help", "notes"}} {
|
||||
var diagnostics bytes.Buffer
|
||||
result := app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: &diagnostics})
|
||||
if result.Kind != commandkit.OutcomeOperational || !result.ReportIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || result.DiagnosticDelivery != commandkit.DeliveryComplete || strings.Count(diagnostics.String(), reportErr.Error()) != 1 {
|
||||
if result.Kind != commandkit.OutcomeOperational || result.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Output.WriteError, reportErr) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete || (!strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output")) {
|
||||
t.Fatalf("%v: result=%#v diagnostics=%q", args, result, diagnostics.String())
|
||||
}
|
||||
result = app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: failingWriter{diagnosticErr}})
|
||||
if result.DiagnosticDelivery != commandkit.DeliveryIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || !errors.Is(result.DeliveryError, diagnosticErr) {
|
||||
if result.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Output.WriteError, reportErr) || result.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Diagnostics.WriteError, diagnosticErr) {
|
||||
t.Fatalf("%v: result=%#v", args, result)
|
||||
}
|
||||
}
|
||||
@@ -560,46 +546,34 @@ func TestExecuteNormalizesOutcomeDefaultsAndPreservesDomainValues(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
|
||||
wantDelivery := commandkit.DeliveryNone
|
||||
if tc.cause != nil {
|
||||
wantDelivery = commandkit.DeliveryComplete
|
||||
}
|
||||
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause || result.DiagnosticDelivery != wantDelivery {
|
||||
var diagnostic bytes.Buffer
|
||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause {
|
||||
t.Fatalf("result=%#v expected=%#v", result, tc)
|
||||
}
|
||||
if tc.status != 0 && !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
|
||||
t.Fatalf("failed outcome omitted its fallback explanation: %q", diagnostic.String())
|
||||
}
|
||||
if tc.cause != nil && strings.Contains(diagnostic.String(), tc.cause.Error()) {
|
||||
t.Fatalf("operation cause was presented automatically: %q", diagnostic.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilDiagnosticWritersHaveNoOutputAndDoNotHideCauses(t *testing.T) {
|
||||
func TestNilDiagnosticWriterDoesNotHideOperationCause(t *testing.T) {
|
||||
cause := errors.New("operation failed")
|
||||
for _, base := range []commandkit.Outcome{commandkit.Success(), commandkit.Domain("needs action", 2, nil), commandkit.Failure(cause)} {
|
||||
base.Diagnostics = []commandkit.Diagnostic{{}, {Severity: commandkit.SeverityWarning}, {Severity: commandkit.SeverityError}}
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return base }})
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
return commandkit.Failure("The operation failed.").WithCause(cause)
|
||||
},
|
||||
})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var diagnostics bytes.Buffer
|
||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostics})
|
||||
want := commandkit.DeliveryNone
|
||||
if base.Cause != nil {
|
||||
want = commandkit.DeliveryComplete
|
||||
}
|
||||
if result.Kind != base.Kind || result.Status != base.Status || result.Domain != base.Domain || result.Cause != base.Cause || result.DeliveryError != nil || result.DiagnosticDelivery != want {
|
||||
t.Fatalf("result=%#v base=%#v", result, base)
|
||||
}
|
||||
if base.Cause == nil && diagnostics.Len() != 0 {
|
||||
t.Fatalf("unexpected output=%q", diagnostics.String())
|
||||
}
|
||||
if base.Cause != nil && strings.Count(diagnostics.String(), cause.Error()) != 1 {
|
||||
t.Fatalf("cause output=%q", diagnostics.String())
|
||||
}
|
||||
if base.Cause != nil {
|
||||
result = app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{io.ErrClosedPipe}})
|
||||
if !errors.Is(result.Cause, cause) || !errors.Is(result.DeliveryError, io.ErrClosedPipe) || result.DiagnosticDelivery != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("failed delivery=%#v", result)
|
||||
}
|
||||
}
|
||||
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
|
||||
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, cause) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
|
||||
t.Fatalf("nil diagnostic destination changed the outcome: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
189
execute_reporting_test.go
Normal file
189
execute_reporting_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExecuteGivesEachInvocationItsReporterAndAppliesSelection(t *testing.T) {
|
||||
var reporters []*Reporter
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(inv Invocation, _ struct{}) Outcome {
|
||||
reporter := inv.Reporter()
|
||||
reporters = append(reporters, reporter)
|
||||
if _, err := fmt.Fprint(reporter.Output(), "result\n"); err != nil {
|
||||
t.Errorf("write result: %v", err)
|
||||
}
|
||||
if err := reporter.Warn("attention"); err != nil {
|
||||
t.Errorf("submit warning: %v", err)
|
||||
}
|
||||
return Outcome{
|
||||
Kind: OutcomeDomain, Domain: "accepted", Status: 0,
|
||||
Presentation: PresentationResult{
|
||||
Output: DeliveryResult{State: DeliveryIncomplete, WriteError: io.ErrClosedPipe},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var quietOut, quietErr bytes.Buffer
|
||||
quiet := app.Execute(context.Background(), []string{"run", "--quiet"}, Streams{Out: &quietOut, Err: &quietErr})
|
||||
if quiet.Kind != OutcomeDomain || quiet.Status != 0 || quiet.Domain != "accepted" {
|
||||
t.Fatalf("quiet outcome = %#v", quiet)
|
||||
}
|
||||
if quietOut.Len() != 0 || quietErr.Len() != 0 {
|
||||
t.Fatalf("quiet output = %q, diagnostics = %q", quietOut.String(), quietErr.String())
|
||||
}
|
||||
if quiet.Presentation.Output.State != DeliverySuppressed || quiet.Presentation.Diagnostics.State != DeliverySuppressed {
|
||||
t.Fatalf("quiet presentation = %#v", quiet.Presentation)
|
||||
}
|
||||
|
||||
var verboseOut, verboseErr bytes.Buffer
|
||||
verbose := app.Execute(context.Background(), []string{"run", "--verbose"}, Streams{Out: &verboseOut, Err: &verboseErr})
|
||||
if verbose.Kind != OutcomeDomain || verbose.Status != 0 {
|
||||
t.Fatalf("verbose outcome = %#v", verbose)
|
||||
}
|
||||
if verboseOut.String() != "result\n" || verboseErr.String() != "WARN: attention\n" {
|
||||
t.Fatalf("verbose output = %q, diagnostics = %q", verboseOut.String(), verboseErr.String())
|
||||
}
|
||||
if verbose.Presentation.Output.State != DeliveryComplete || verbose.Presentation.Diagnostics.State != DeliveryComplete {
|
||||
t.Fatalf("verbose presentation = %#v", verbose.Presentation)
|
||||
}
|
||||
if len(reporters) != 2 || reporters[0] == nil || reporters[1] == nil || reporters[0] == reporters[1] {
|
||||
t.Fatalf("invocation reporters = %#v", reporters)
|
||||
}
|
||||
if _, err := reporters[0].Output().Write([]byte("late")); !errors.Is(err, ErrReporterClosed) {
|
||||
t.Fatalf("write through finalized reporter error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteQuietUsageKeepsAliasGuidanceVisible(t *testing.T) {
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, command, Alias("r", "run"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"r", "--quiet", "--unknown"}, Streams{Out: &output, Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeUsage || outcome.Status != 2 {
|
||||
t.Fatalf("outcome = %#v", outcome)
|
||||
}
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("usage output = %q", output.String())
|
||||
}
|
||||
if got := diagnostics.String(); !strings.Contains(got, "ERROR: unknown flag") || !strings.Contains(got, "Usage: Example r") || !strings.Contains(got, "Alias for: Example run") {
|
||||
t.Fatalf("usage diagnostics = %q", got)
|
||||
}
|
||||
if outcome.Presentation.Diagnostics.State != DeliveryComplete {
|
||||
t.Fatalf("diagnostic presentation = %#v", outcome.Presentation.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnresolvedRoutesUseReportingAliasesForTheirParseContext(t *testing.T) {
|
||||
calls := 0
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
NewInput: func() struct{} { calls++; return struct{}{} },
|
||||
Run: func(Invocation, struct{}) Outcome { calls++; return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, Group("group", "", Group("nested", "", command)),
|
||||
FlagAlias("-d", "--debug"), FlagAlias("-q", "--quiet"),
|
||||
FlagAlias("-x", "--quiet"), FlagAliasFor("group nested", "-x", "--debug"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range [][]string{{"missing"}, {"group", "missing"}, {"group", "nested", "missing"}} {
|
||||
t.Run(strings.Join(path, "/"), func(t *testing.T) {
|
||||
var canonicalDiagnostics, aliasDiagnostics bytes.Buffer
|
||||
canonical := app.Execute(context.Background(), append(append([]string(nil), path...), "--debug"), Streams{Err: &canonicalDiagnostics})
|
||||
alias := app.Execute(context.Background(), append(append([]string(nil), path...), "-d"), Streams{Err: &aliasDiagnostics})
|
||||
if canonical.Kind != OutcomeUsage || alias.Kind != OutcomeUsage || canonical.Status != alias.Status || canonical.Explanation.Message != alias.Explanation.Message {
|
||||
t.Fatalf("canonical=%#v alias=%#v", canonical, alias)
|
||||
}
|
||||
for _, text := range []string{canonicalDiagnostics.String(), aliasDiagnostics.String()} {
|
||||
if !strings.Contains(text, "DEBUG:") || !strings.Contains(text, "ERROR:") || strings.Contains(text, "unknown shorthand") || strings.Contains(text, "Alias for:") {
|
||||
t.Fatalf("diagnostics=%q", text)
|
||||
}
|
||||
}
|
||||
route := resolveEnteredRoute(path, app.routeSnapshot())
|
||||
if route.kind != routeUnknown || len(route.canonicalPath) != 0 {
|
||||
t.Fatalf("invalid route acquired canonical identity: %#v", route)
|
||||
}
|
||||
var quiet bytes.Buffer
|
||||
got := app.Execute(context.Background(), append(append([]string(nil), path...), "-q", "--unknown"), Streams{Err: &quiet})
|
||||
if got.Kind != OutcomeUsage || !strings.Contains(quiet.String(), "ERROR: unknown flag") || strings.Contains(quiet.String(), "DEBUG:") {
|
||||
t.Fatalf("quiet alias outcome=%#v diagnostics=%q", got, quiet.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
var scoped bytes.Buffer
|
||||
got := app.Execute(context.Background(), []string{"group", "nested", "missing", "-x"}, Streams{Err: &scoped})
|
||||
if got.Kind != OutcomeUsage || !strings.Contains(scoped.String(), "DEBUG:") {
|
||||
t.Fatalf("scoped precedence outcome=%#v diagnostics=%q", got, scoped.String())
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("invalid routes ran factories or handlers %d times", calls)
|
||||
}
|
||||
}
|
||||
|
||||
type executeReportingFailureWriter struct{ err error }
|
||||
|
||||
func (w executeReportingFailureWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||
|
||||
func TestExecuteFinalizesEarlyAndIgnoredWriterFailures(t *testing.T) {
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(inv Invocation, _ struct{}) Outcome {
|
||||
_, _ = inv.Reporter().Output().Write([]byte("report"))
|
||||
return Success()
|
||||
},
|
||||
})
|
||||
app, err := New(Identity{Name: "Example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeErr := errors.New("result destination failed")
|
||||
var diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"run"}, Streams{
|
||||
Out: executeReportingFailureWriter{err: writeErr}, Err: &diagnostics,
|
||||
})
|
||||
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
|
||||
t.Fatalf("ignored output failure outcome = %#v", outcome)
|
||||
}
|
||||
if !strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output") {
|
||||
t.Fatalf("fallback diagnostic = %q", diagnostics.String())
|
||||
}
|
||||
|
||||
diagnostics.Reset()
|
||||
outcome = app.Execute(nil, []string{"run"}, Streams{Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil {
|
||||
t.Fatalf("nil context outcome = %#v", outcome)
|
||||
}
|
||||
if !strings.Contains(diagnostics.String(), "ERROR: invocation context must not be nil\n") {
|
||||
t.Fatalf("nil context diagnostic = %q", diagnostics.String())
|
||||
}
|
||||
|
||||
diagnostics.Reset()
|
||||
var nilApp *App
|
||||
outcome = nilApp.Execute(context.Background(), []string{"run"}, Streams{Err: &diagnostics})
|
||||
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil {
|
||||
t.Fatalf("nil app outcome = %#v", outcome)
|
||||
}
|
||||
if !strings.Contains(diagnostics.String(), "ERROR: cannot execute commands on a nil app\n") {
|
||||
t.Fatalf("nil app diagnostic = %q", diagnostics.String())
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -9,10 +9,10 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // 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
|
||||
)
|
||||
|
||||
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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
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/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||
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/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
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
|
||||
}
|
||||
|
||||
// Streams contains the output destinations associated with a command
|
||||
// invocation.
|
||||
// Streams contains the caller-owned output destinations associated with an
|
||||
// invocation. Out receives substantive results; Err receives messages,
|
||||
// terminal explanations, usage guidance, and CommandKit diagnostics. Nil
|
||||
// destinations are replaced with io.Discard. CommandKit does not close or flush
|
||||
// supplied writers.
|
||||
type Streams struct {
|
||||
// Out receives help and command reports.
|
||||
Out io.Writer
|
||||
@@ -27,11 +30,41 @@ type Streams struct {
|
||||
Err io.Writer
|
||||
}
|
||||
|
||||
// Invocation provides execution context and output streams to a command
|
||||
// handler.
|
||||
func normalizeStreams(streams Streams) Streams {
|
||||
if streams.Out == nil {
|
||||
streams.Out = io.Discard
|
||||
}
|
||||
if streams.Err == nil {
|
||||
streams.Err = io.Discard
|
||||
}
|
||||
return streams
|
||||
}
|
||||
|
||||
// Invocation provides execution context and invocation-scoped reporting to a
|
||||
// command handler. Execute finalizes its reporter before returning. For command
|
||||
// aliases, EnteredPath and CanonicalPath expose the route the caller used and
|
||||
// the selected canonical command path. Both omit the executable name, flags,
|
||||
// and operands.
|
||||
type Invocation struct {
|
||||
// Context carries caller cancellation and request-scoped values.
|
||||
Context context.Context
|
||||
// Streams are the caller-selected output destinations.
|
||||
Streams Streams
|
||||
|
||||
reporter *Reporter
|
||||
enteredPath string
|
||||
canonicalPath string
|
||||
}
|
||||
|
||||
// Reporter returns the reporting facility supplied for this invocation. A
|
||||
// manually constructed Invocation has no reporter.
|
||||
func (inv Invocation) Reporter() *Reporter { return inv.reporter }
|
||||
|
||||
// EnteredPath returns the command path used to invoke the selected command,
|
||||
// excluding the executable name, flags, and operands. It is empty for an
|
||||
// Invocation that was not created by command execution.
|
||||
func (inv Invocation) EnteredPath() string { return inv.enteredPath }
|
||||
|
||||
// CanonicalPath returns the selected command's canonical path after resolving
|
||||
// aliases, excluding the executable name, flags, and operands. It equals
|
||||
// EnteredPath for a direct canonical invocation and is empty for an Invocation
|
||||
// that was not created by command execution.
|
||||
func (inv Invocation) CanonicalPath() string { return inv.canonicalPath }
|
||||
|
||||
@@ -7,10 +7,9 @@ itself.
|
||||
|
||||
## Module
|
||||
|
||||
The module path is
|
||||
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig`. Its first
|
||||
release is prepared as `v0.1.0`. It uses the core `commandkit` package and
|
||||
ConfigKit; it does not import PromptKit. See the
|
||||
The module path is `go.maximumdirect.net/ckconfig`. The prepared v0.3.0
|
||||
candidate requires Go 1.27.0 and uses `go.maximumdirect.net/commandkit v0.3.0` and
|
||||
`go.maximumdirect.net/configkit v1.1.0`; it does not import PromptKit. See the
|
||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
|
||||
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
|
||||
ConfigKit loader. The `config` command family exposes scope discovery and the
|
||||
validate, show, sources, explain, and diff operations supported by the declared
|
||||
scopes. Source reports include native destination, placement, unresolved
|
||||
destination, and source version-check evidence. Comparison returns ConfigKit's
|
||||
native records to the YAML report path; the
|
||||
scopes. Source reports include native destination, placement, destination
|
||||
fallback, version evidence, and empty-import selections. Comparison returns
|
||||
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)
|
||||
owns its operator details.
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
type adapterSettings struct {
|
||||
@@ -27,7 +27,7 @@ func adapterDefinition() configkit.Definition[adapterSettings] {
|
||||
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 {
|
||||
if value.Greeting != "resolved" {
|
||||
value.Greeting = "resolved"
|
||||
resolution.Record(configkit.Keys("greeting"), configkit.ChangeNormalization, "canonical greeting")
|
||||
resolution.RecordNormalization(configkit.Keys("greeting"), "canonical greeting")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -192,14 +192,14 @@ func TestFromConfigKitCompareRetainsSideDiagnosticsAndLoadError(t *testing.T) {
|
||||
if !errors.As(gotErr, &typed) || typed != loadErr {
|
||||
t.Fatalf("load error type not preserved: %#v", typed)
|
||||
}
|
||||
if len(acquired.Diagnostics) != 2 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || acquired.Diagnostics[1].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("retained diagnostics = %#v", acquired.Diagnostics)
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || len(acquired.Explanation.Details) != 1 {
|
||||
t.Fatalf("retained diagnostics/explanation = %#v / %#v", acquired.Diagnostics, acquired.Explanation)
|
||||
}
|
||||
var leftText, rightText bytes.Buffer
|
||||
if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := acquired.Diagnostics[1].Write(&rightText); err != nil {
|
||||
if err := acquired.Explanation.Details[0](&rightText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) {
|
||||
@@ -231,8 +231,15 @@ func TestFromConfigKitCompareStopsAfterLeftLoadFailure(t *testing.T) {
|
||||
t.Fatalf("left failure/right load = %v / %v", err, rightCalled)
|
||||
}
|
||||
var typed *configkit.LoadError
|
||||
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("left failure identity/diagnostics = %v / %#v", err, acquired.Diagnostics)
|
||||
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Explanation.Details) != 1 {
|
||||
t.Fatalf("left failure identity/explanation = %v / %#v", err, acquired.Explanation)
|
||||
}
|
||||
var terminal bytes.Buffer
|
||||
if err := acquired.Explanation.Details[0](&terminal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(terminal.String(), `Configuration scope "app" left:`) || !strings.Contains(terminal.String(), `"left-source"`) {
|
||||
t.Fatalf("left terminal context lost side/source identity: %q", terminal.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
func TestComparisonCapabilitiesAreIndependentAndCaptured(t *testing.T) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
func TestComparisonSelectionInheritsFieldsIndependently(t *testing.T) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
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"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
result, err := load(ctx, request.Selection, input, state)
|
||||
acquired := commandkit.Acquired[View]{}
|
||||
if result != nil {
|
||||
acquired.Value = View{Effective: result, Sources: result, Explanation: result}
|
||||
acquired.Diagnostics, acquired.Explanation = configKitDiagnostics(name, result.Sources(), result.Diagnostics())
|
||||
}
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) && loadError != nil {
|
||||
return commandkit.Acquired[View]{Diagnostics: configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err
|
||||
warnings, explanation := configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())
|
||||
acquired.Diagnostics = append(acquired.Diagnostics, warnings...)
|
||||
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, explanation)
|
||||
}
|
||||
return commandkit.Acquired[View]{}, err
|
||||
return acquired, err
|
||||
}
|
||||
if result == nil {
|
||||
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
||||
}
|
||||
|
||||
sources := result.Sources()
|
||||
return commandkit.Acquired[View]{
|
||||
Value: View{Effective: result, Sources: result, Explanation: result},
|
||||
Diagnostics: configKitDiagnostics(name, sources, result.Diagnostics()),
|
||||
}, nil
|
||||
return acquired, nil
|
||||
}
|
||||
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err)
|
||||
}
|
||||
|
||||
left, leftDiagnostics, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
|
||||
acquired := commandkit.Acquired[[]configkit.Difference]{}
|
||||
left, leftDiagnostics, leftExplanation, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
|
||||
acquired.Diagnostics = append(acquired.Diagnostics, leftDiagnostics...)
|
||||
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, leftExplanation)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
|
||||
return acquired, err
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
|
||||
return acquired, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
|
||||
}
|
||||
|
||||
right, rightDiagnostics, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
|
||||
diagnostics = append(diagnostics, rightDiagnostics...)
|
||||
right, rightDiagnostics, rightExplanation, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
|
||||
acquired.Diagnostics = append(acquired.Diagnostics, rightDiagnostics...)
|
||||
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, rightExplanation)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
|
||||
return acquired, err
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
|
||||
return acquired, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
|
||||
}
|
||||
|
||||
differences, err := configkit.Diff(left, right)
|
||||
acquired.Value, err = configkit.Diff(left, right)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
|
||||
return acquired, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
|
||||
}
|
||||
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil
|
||||
return acquired, nil
|
||||
}
|
||||
return scope
|
||||
}
|
||||
@@ -90,50 +95,74 @@ func loadComparisonSide[I, S, T any](
|
||||
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
|
||||
request ComparisonSide[I],
|
||||
state S,
|
||||
) (*configkit.Result[T], []commandkit.Diagnostic, error) {
|
||||
) (*configkit.Result[T], []commandkit.Diagnostic, commandkit.Explanation, error) {
|
||||
result, err := load(ctx, request.Selection, request.Input, state)
|
||||
var diagnostics []commandkit.Diagnostic
|
||||
var explanation commandkit.Explanation
|
||||
if result != nil {
|
||||
warnings, terminal := configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics())
|
||||
diagnostics = append(diagnostics, warnings...)
|
||||
explanation = appendConfigKitExplanation(explanation, terminal)
|
||||
}
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
var diagnostics []commandkit.Diagnostic
|
||||
if errors.As(err, &loadError) && loadError != nil {
|
||||
diagnostics = configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
|
||||
warnings, terminal := configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
|
||||
diagnostics = append(diagnostics, warnings...)
|
||||
explanation = appendConfigKitExplanation(explanation, terminal)
|
||||
}
|
||||
return nil, diagnostics, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
|
||||
return result, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
|
||||
}
|
||||
if result == nil {
|
||||
return nil, nil, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
|
||||
return nil, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
|
||||
}
|
||||
return result, configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics()), nil
|
||||
return result, diagnostics, explanation, nil
|
||||
}
|
||||
|
||||
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
|
||||
}
|
||||
|
||||
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
|
||||
}
|
||||
|
||||
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
return nil, commandkit.Explanation{}
|
||||
}
|
||||
|
||||
severity := commandkit.SeverityWarning
|
||||
sources = append([]configkit.SourceInfo(nil), sources...)
|
||||
records = cloneConfigKitDiagnostics(records)
|
||||
warnings := make([]configkit.Diagnostic, 0, len(records))
|
||||
terminal := make([]configkit.Diagnostic, 0, len(records))
|
||||
for _, record := range records {
|
||||
if record.Severity == configkit.SeverityError {
|
||||
severity = commandkit.SeverityError
|
||||
break
|
||||
terminal = append(terminal, record)
|
||||
} else {
|
||||
warnings = append(warnings, record)
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the upstream snapshots so later calls to Write do not depend on
|
||||
// slices returned by the provider.
|
||||
sources = append([]configkit.SourceInfo(nil), sources...)
|
||||
records = append([]configkit.Diagnostic(nil), records...)
|
||||
return []commandkit.Diagnostic{{
|
||||
Severity: severity,
|
||||
Write: func(writer io.Writer) error {
|
||||
if len(warnings) == 0 {
|
||||
return nil, explanationWithConfigKitRecords(heading, sources, terminal)
|
||||
}
|
||||
warning := commandkit.Diagnostic{
|
||||
Severity: commandkit.SeverityWarning,
|
||||
Write: configKitDiagnosticWriter(heading, sources, warnings),
|
||||
}
|
||||
return []commandkit.Diagnostic{warning}, explanationWithConfigKitRecords(heading, sources, terminal)
|
||||
}
|
||||
|
||||
func explanationWithConfigKitRecords(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Explanation {
|
||||
if len(records) == 0 {
|
||||
return commandkit.Explanation{}
|
||||
}
|
||||
return commandkit.Explanation{Details: []commandkit.Detail{configKitDiagnosticWriter(heading, sources, records)}}
|
||||
}
|
||||
|
||||
func configKitDiagnosticWriter(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Detail {
|
||||
return func(writer io.Writer) error {
|
||||
if err := writeDiagnosticText(writer, heading); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -149,8 +178,27 @@ func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceI
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
// 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 []Operation
|
||||
// Acquire loads the selected configuration for one ordinary inspection
|
||||
// invocation.
|
||||
// invocation. It returns independent diagnostics and terminal context
|
||||
// separately from the native value and optional operation error.
|
||||
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
|
||||
// Compare compares two selections for this scope using one shared state.
|
||||
// Its Acquired result carries native difference records and diagnostics;
|
||||
// returned errors retain their supported cause identity. It is required when
|
||||
// Diff is declared in Operations.
|
||||
// Its Acquired result carries native difference records, independent
|
||||
// diagnostics, and terminal context; returned errors retain their supported
|
||||
// cause identity. It is required when Diff is declared in Operations.
|
||||
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
|
||||
|
||||
issues []error
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
func TestWithScopeErrorCopiesAndRetainsCauses(t *testing.T) {
|
||||
@@ -126,14 +126,20 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
||||
type settings struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
_, loadErr := configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[settings]{Definition: configkit.Definition[settings]{}})
|
||||
path := configkit.Keys("schema_version")
|
||||
_, loadErr := configkit.Load(configkit.Bytes("bad source", []byte("{}\n")), configkit.Options[struct{}]{
|
||||
Definition: configkit.Definition[struct{}]{Version: &configkit.VersionRule{
|
||||
Path: path,
|
||||
Check: func(_ configkit.Version, validation *configkit.Validation) error {
|
||||
validation.Warning(path, "review schema compatibility")
|
||||
return errors.New("private checker error")
|
||||
},
|
||||
}},
|
||||
})
|
||||
if loadErr == nil {
|
||||
t.Fatal("fixture did not produce a ConfigKit load error")
|
||||
}
|
||||
scope := FromConfigKit[struct{}, struct{}, settings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[settings], error) {
|
||||
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) {
|
||||
return nil, loadErr
|
||||
})
|
||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||
@@ -144,38 +150,121 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
||||
if !errors.As(gotErr, &typed) {
|
||||
t.Fatalf("load error type was not retained: %T", gotErr)
|
||||
}
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("failure diagnostics = %#v", acquired.Diagnostics)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := acquired.Diagnostics[0].Write(&rendered); err != nil {
|
||||
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
|
||||
t.Fatalf("failure explanation = %#v", acquired.Explanation)
|
||||
}
|
||||
var warning, terminal bytes.Buffer
|
||||
if err := acquired.Diagnostics[0].Write(&warning); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) {
|
||||
t.Fatalf("diagnostic output lacks scope/source legend: %q", rendered.String())
|
||||
if err := acquired.Explanation.Details[0](&terminal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(warning.String(), `Configuration scope "app":`) || !strings.Contains(warning.String(), `review schema compatibility`) {
|
||||
t.Fatalf("warning output lost its scope or record: %q", warning.String())
|
||||
}
|
||||
if !strings.Contains(terminal.String(), `Configuration scope "app":`) || !strings.Contains(terminal.String(), `"bad source"`) || strings.Contains(terminal.String(), "review schema compatibility") {
|
||||
t.Fatalf("terminal explanation lost native context or included an independent warning: %q", terminal.String())
|
||||
}
|
||||
if strings.Contains(terminal.String(), "private checker error") {
|
||||
t.Fatalf("terminal explanation exposed the upstream cause: %q", terminal.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigKitPreservesValueAndDiagnosticsAlongsideError(t *testing.T) {
|
||||
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loadErr := errors.New("loader returned a value with an error")
|
||||
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
|
||||
return result, loadErr
|
||||
})
|
||||
|
||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||
if gotErr != loadErr {
|
||||
t.Fatalf("load error identity = %v, want %v", gotErr, loadErr)
|
||||
}
|
||||
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result {
|
||||
t.Fatalf("acquired value facets were lost: %#v", acquired.Value)
|
||||
}
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("independent diagnostics = %#v", acquired.Diagnostics)
|
||||
}
|
||||
if len(acquired.Explanation.Details) != 0 {
|
||||
t.Fatalf("warning-only result acquired terminal context: %#v", acquired.Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
|
||||
warning := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
|
||||
warning, noExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
|
||||
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("warning diagnostics = %#v", warning)
|
||||
}
|
||||
withError := configKitDiagnostics("app", nil, []configkit.Diagnostic{
|
||||
if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
|
||||
t.Fatalf("warning-only explanation = %#v", noExplanation)
|
||||
}
|
||||
withWarning, withExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{
|
||||
{Severity: configkit.SeverityWarning, Message: "safe warning"},
|
||||
{Severity: configkit.SeverityError, Message: "safe error"},
|
||||
})
|
||||
if withError[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("mixed diagnostics severity = %q", withError[0].Severity)
|
||||
if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
|
||||
t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation)
|
||||
}
|
||||
|
||||
if err := withError[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||
if err := withWarning[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
|
||||
}
|
||||
if err := withExplanation.Details[0](shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("terminal short writer error = %v, want %v", err, io.ErrShortWrite)
|
||||
}
|
||||
wantErr := errors.New("writer failed")
|
||||
if err := withError[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
|
||||
if err := withWarning[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("writer error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if err := withExplanation.Details[0](failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("terminal writer error = %v, want %v", err, wantErr)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.T) {
|
||||
primary := &configkit.Location{SourceID: "primary-source", Line: 4, Column: 2}
|
||||
related := []configkit.Location{{SourceID: "related-source", Line: 2, Column: 1}}
|
||||
sources := []configkit.SourceInfo{
|
||||
{ID: "primary-source", Label: "primary", Path: "primary.yml"},
|
||||
{ID: "related-source", Label: "related", Path: "related.yml"},
|
||||
}
|
||||
records := []configkit.Diagnostic{{
|
||||
Severity: configkit.SeverityError,
|
||||
Path: configkit.Keys("name"),
|
||||
Message: "native terminal context",
|
||||
Primary: primary,
|
||||
Related: related,
|
||||
}}
|
||||
_, explanation := configKitDiagnostics("app", sources, records)
|
||||
primary.SourceID = "mutated-primary"
|
||||
related[0].SourceID = "mutated-related"
|
||||
sources[0].Path = "mutated.yml"
|
||||
|
||||
if len(explanation.Details) != 1 {
|
||||
t.Fatalf("explanation = %#v", explanation)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := explanation.Details[0](&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, fragment := range []string{"primary.yml", "primary-source", "related-source", "native terminal context"} {
|
||||
if !strings.Contains(output.String(), fragment) {
|
||||
t.Errorf("terminal context %q omits %q", output.String(), fragment)
|
||||
}
|
||||
}
|
||||
if strings.Contains(output.String(), "mutated-") || strings.Contains(output.String(), "mutated.yml") {
|
||||
t.Fatalf("terminal renderer retained caller mutations: %q", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
type shortWriter struct{}
|
||||
|
||||
@@ -4,40 +4,33 @@ import (
|
||||
"errors"
|
||||
"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 {
|
||||
if plan.comparison == nil || plan.comparison.scope == nil {
|
||||
return configOperationFailure(errors.New("configuration comparison was not prepared"), nil, false)
|
||||
cause := errors.New("configuration comparison was not prepared")
|
||||
return configFailure("Configuration comparison was not prepared.", commandkit.Explanation{}, cause)
|
||||
}
|
||||
selected := plan.comparison
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, nil, false)
|
||||
return configFailure("Configuration comparison was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
compare := selected.scope.scope.Compare
|
||||
if compare == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name), nil, false)
|
||||
cause := fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name)
|
||||
return configFailure("Configuration scope does not support comparison.", commandkit.Explanation{}, cause)
|
||||
}
|
||||
acquired, err := compare(invocation.Context, selected.request, state)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
|
||||
reporter := invocation.Reporter()
|
||||
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||
if err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure(fmt.Sprintf("Unable to compare configuration scope %q.", selected.scope.scope.Name), acquired.Explanation, err)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration comparison was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
report, err := renderDiffReport(acquired.Value)
|
||||
if err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, report); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||
return renderDiffReport(acquired.Value)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -101,21 +101,30 @@ func TestConfigDiffRunsComparisonOnlyScopeAndRejectsUnsupportedScopeBeforeWork(t
|
||||
|
||||
func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) {
|
||||
wantErr := errors.New("provider unavailable")
|
||||
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error {
|
||||
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "provider warning\n")
|
||||
return err
|
||||
}}
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, fmt.Errorf("comparison setup: %w", wantErr)
|
||||
return commandkit.Acquired[[]configkit.Difference]{
|
||||
Diagnostics: []commandkit.Diagnostic{diagnostic},
|
||||
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "native comparison context\n")
|
||||
return err
|
||||
}}},
|
||||
}, fmt.Errorf("comparison setup: %w", wantErr)
|
||||
},
|
||||
}}}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
var output, diagnosticOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || output.Len() != 0 {
|
||||
t.Fatalf("provider failure outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
if got := diagnosticOutput.String(); !strings.Contains(got, "provider warning") || !strings.Contains(got, "native comparison context") || strings.Index(got, "provider warning") > strings.Index(got, "native comparison context") || strings.Contains(got, wantErr.Error()) {
|
||||
t.Fatalf("provider messages and terminal context = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
|
||||
@@ -128,8 +137,8 @@ func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
|
||||
},
|
||||
}}}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 {
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--quiet"}, commandkit.Streams{Out: failingOutput{err: errors.New("quiet destination should not be called")}, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || outcome.Presentation.Output.RenderError == nil || output.Len() != 0 {
|
||||
t.Fatalf("report preparation failure outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
}
|
||||
@@ -146,8 +155,8 @@ func TestConfigDiffCancellationAfterAcquisitionRetainsDiagnosticsWithoutWriting(
|
||||
}}}))
|
||||
defer cancel()
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(ctx, []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || outcome.ReportIncomplete || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||
outcome := app.Execute(ctx, []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || output.Len() != 0 {
|
||||
t.Fatalf("cancelled comparison outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
}
|
||||
@@ -170,13 +179,36 @@ func TestConfigDiffOutputErrorsCarryIncompleteDeliveryOutcome(t *testing.T) {
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, test.want) || !errors.Is(outcome.Cause, test.want) || !outcome.ReportIncomplete {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("delivery failure outcome = %#v", outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffDiagnosticDestinationFailureIsPresentationEvidence(t *testing.T) {
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{
|
||||
Diagnostics: []commandkit.Diagnostic{{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "provider warning\n")
|
||||
return err
|
||||
}}},
|
||||
}, nil
|
||||
},
|
||||
}}}))
|
||||
writeErr := errors.New("diagnostic destination failed")
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: failingOutput{err: writeErr}})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("diagnostic delivery failure outcome = %#v", outcome)
|
||||
}
|
||||
if output.Len() == 0 {
|
||||
t.Fatal("healthy result destination did not receive the diff report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
|
||||
type input struct {
|
||||
ID int
|
||||
@@ -241,7 +273,7 @@ func TestConfigDiffExecutesConcealedChangesAndEmptyResults(t *testing.T) {
|
||||
if selection.Config.Set && selection.Config.Value == "after" {
|
||||
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
|
||||
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"
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -119,7 +119,7 @@ func TestRenderDiffReportPreservesNativeMasksAndMarkerLookingStrings(t *testing.
|
||||
Public string `yaml:"public"`
|
||||
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] {
|
||||
result, err := configkit.Load(configkit.Mapping("configuration", map[string]any{"public": public, "secret": secret}), configkit.Options[settings]{Definition: definition})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Package ckconfig provides CommandKit's optional ConfigKit integration. Its
|
||||
// declarations install the standard config command family through the core
|
||||
// commandkit package. Source reports preserve ConfigKit's destination,
|
||||
// placement, and version-check evidence; importing ckconfig alone installs no
|
||||
// commands.
|
||||
// Package ckconfig, provided by go.maximumdirect.net/ckconfig, provides
|
||||
// CommandKit's optional ConfigKit v1.1.0 integration, requiring Go 1.27.0. Its
|
||||
// canonical Command declaration installs the standard config command family
|
||||
// through the core commandkit package. Applications can install CommandKit
|
||||
// RoutingRule declarations alongside it through commandkit.New or
|
||||
// App.Register. Source reports preserve ConfigKit's destination, placement,
|
||||
// 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
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
## 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
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
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
|
||||
|
||||
@@ -153,16 +153,16 @@ License and notices: [MIT](licenses/MIT.txt) |
|
||||
|
||||
## 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
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
go run . config show
|
||||
go run . config show --config fixtures/before.yml --verbose
|
||||
go run . config show --config fixtures/before.yml --quiet
|
||||
go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml
|
||||
go run . greet
|
||||
go run . greet --count=0 --tag=first --tag second
|
||||
@@ -15,3 +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
|
||||
configuration and overlay are passed to ConfigKit unchanged. The comparison
|
||||
command loads the two local fixtures and reports `changes.greeting: Goodbye`.
|
||||
The before fixture uses schema version 1, which ConfigKit reports as a provider
|
||||
warning in verbose mode. Ordinary mode hides the warning while preserving the
|
||||
effective YAML report; quiet mode suppresses that report.
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
schema:
|
||||
version: 2
|
||||
greeting: Goodbye
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
schema:
|
||||
version: 1
|
||||
greeting: Hello
|
||||
|
||||
@@ -3,15 +3,19 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.maximumdirect.net/ckconfig"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
type appConfiguration struct {
|
||||
Schema struct {
|
||||
Version int `yaml:"version"`
|
||||
} `yaml:"schema"`
|
||||
Greeting string `yaml:"greeting"`
|
||||
}
|
||||
|
||||
@@ -22,12 +26,25 @@ type greetInput struct {
|
||||
|
||||
func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*configkit.Result[appConfiguration], error) {
|
||||
source := configkit.Mapping("simple application settings", map[string]any{
|
||||
"schema": map[string]any{"version": 2},
|
||||
"greeting": "Hello",
|
||||
})
|
||||
if selection.Config.Set {
|
||||
source = configkit.File(selection.Config.Value)
|
||||
}
|
||||
options := configkit.Options[appConfiguration]{}
|
||||
options := configkit.Options[appConfiguration]{
|
||||
Definition: configkit.Definition[appConfiguration]{
|
||||
Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("schema", "version"),
|
||||
Check: func(version configkit.Version, validation *configkit.Validation) error {
|
||||
if version.Text == "1" {
|
||||
validation.Warning(configkit.Keys("schema", "version"), "schema version 1 is supported; upgrade to version 2 before the next example release")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if selection.Overlay.Set {
|
||||
options.Overlay = selection.Overlay.Value
|
||||
options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."}
|
||||
@@ -50,9 +67,10 @@ func newApp() (*commandkit.App, error) {
|
||||
}),
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
||||
reporter := invocation.Reporter()
|
||||
configuration, err := loadAppConfiguration(invocation.Context, ckconfig.Selection{})
|
||||
if err != nil {
|
||||
return commandkit.Failure(err)
|
||||
return commandkit.Failure("Could not load application configuration.").WithCause(err)
|
||||
}
|
||||
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
|
||||
if input.Count.Set {
|
||||
@@ -62,9 +80,10 @@ func newApp() (*commandkit.App, error) {
|
||||
if input.Tags.Set {
|
||||
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
|
||||
}
|
||||
if _, err := fmt.Fprintf(invocation.Streams.Out, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
_ = reporter.RenderOutput(func(writer io.Writer) error {
|
||||
_, err := fmt.Fprintf(writer, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags)
|
||||
return err
|
||||
})
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
"go.maximumdirect.net/ckconfig"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -42,7 +43,7 @@ func TestSimpleAppComparesFixturesAndKeepsDefaultCommands(t *testing.T) {
|
||||
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "greeting: Hello\n" {
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "greeting: Hello\nschema:\n version: 2\n" {
|
||||
t.Fatalf("default show outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
|
||||
@@ -60,6 +61,73 @@ func TestSimpleLoaderDoesNotReplaceExplicitEmptyConfiguration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleConfigurationReportingModes(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantOutput string
|
||||
wantDiagnostic string
|
||||
quiet bool
|
||||
}{
|
||||
{
|
||||
name: "ordinary output hides provider warning",
|
||||
args: []string{"config", "show", "--config", "fixtures/before.yml"},
|
||||
wantOutput: "greeting: Hello\nschema:\n version: 1\n",
|
||||
},
|
||||
{
|
||||
name: "quiet suppresses configuration report",
|
||||
args: []string{"config", "show", "--config", "fixtures/before.yml", "--quiet"},
|
||||
quiet: true,
|
||||
},
|
||||
{
|
||||
name: "verbose shows ConfigKit warning",
|
||||
args: []string{"config", "show", "--config", "fixtures/before.yml", "--verbose"},
|
||||
wantOutput: "greeting: Hello\nschema:\n version: 1\n",
|
||||
wantDiagnostic: "schema version 1 is supported",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
outcome, output, diagnostics := executeSimple(t, test.args...)
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil {
|
||||
t.Fatalf("outcome = %#v", outcome)
|
||||
}
|
||||
if output != test.wantOutput {
|
||||
t.Errorf("output = %q, want %q", output, test.wantOutput)
|
||||
}
|
||||
if test.wantDiagnostic != "" && !strings.Contains(diagnostics, test.wantDiagnostic) {
|
||||
t.Errorf("diagnostics = %q, want provider warning %q", diagnostics, test.wantDiagnostic)
|
||||
}
|
||||
if test.wantDiagnostic == "" && diagnostics != "" {
|
||||
t.Errorf("diagnostics = %q, want no warning outside verbose mode", diagnostics)
|
||||
}
|
||||
if strings.Contains(diagnostics, "ERROR:") {
|
||||
t.Errorf("diagnostics %q unexpectedly contain a terminal failure", diagnostics)
|
||||
}
|
||||
if test.quiet && (outcome.Presentation.Output.State != commandkit.DeliverySuppressed || outcome.Presentation.Diagnostics.State != commandkit.DeliverySuppressed) {
|
||||
t.Errorf("quiet presentation = %#v, want report and provider warning suppressed", outcome.Presentation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCustomCommandUsesSharedReportingFlags(t *testing.T) {
|
||||
outcome, output, diagnostics := executeSimple(t, "greet", "--quiet")
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || output != "" || diagnostics != "" {
|
||||
t.Fatalf("quiet greet outcome/output/diagnostics = %#v / %q / %q", outcome, output, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func executeSimple(t *testing.T, args ...string) (commandkit.Outcome, string, string) {
|
||||
t.Helper()
|
||||
app, err := newApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostics bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostics})
|
||||
return outcome, output.String(), diagnostics.String()
|
||||
}
|
||||
|
||||
func field(mapping *yaml.Node, name string) *yaml.Node {
|
||||
if mapping == nil || mapping.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
|
||||
@@ -2,12 +2,11 @@ package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
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 {
|
||||
selected := plan.scopes[0]
|
||||
name := selected.scope.scope.Name
|
||||
reporter := invocation.Reporter()
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, nil, false)
|
||||
return configFailure("Configuration explanation was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
if selected.scope.scope.Acquire == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false)
|
||||
cause := fmt.Errorf("configuration scope %q has no acquisition callback", name)
|
||||
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), commandkit.Explanation{}, cause)
|
||||
}
|
||||
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||
Operation: Explain,
|
||||
Selection: plan.selection,
|
||||
}, selected.input, state)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
|
||||
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||
if acquireErr != nil {
|
||||
return configOperationFailure(acquireErr, diagnostics, false)
|
||||
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), acquired.Explanation, acquireErr)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
if acquired.Value.Explanation == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide an explanation reader", name), diagnostics, false)
|
||||
cause := fmt.Errorf("configuration scope %q did not provide an explanation reader", name)
|
||||
return configFailure("Configuration scope did not provide explanation data.", acquired.Explanation, cause)
|
||||
}
|
||||
|
||||
explanation, found := acquired.Value.Explanation.Explain(plan.path)
|
||||
if !found {
|
||||
return configOperationFailure(fmt.Errorf("configuration path %q is not known in scope %q", plan.path.String(), name), diagnostics, false)
|
||||
cause := fmt.Errorf("configuration path %q is not known in scope %q", plan.path.String(), name)
|
||||
return configFailure("The requested configuration path is not available.", acquired.Explanation, cause)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
if plan.format != "yaml" {
|
||||
return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, plan.path, diagnostics)
|
||||
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||
return acquired.Value.Explanation.WriteExplanation(writer, plan.path)
|
||||
})
|
||||
if err != nil && invocation.Context.Err() != nil {
|
||||
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
|
||||
}
|
||||
return commandkit.Success()
|
||||
}
|
||||
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||
var report bytes.Buffer
|
||||
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return nil, err
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path configkit.Path, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
checked := &checkedWriter{writer: writer}
|
||||
renderErr := reader.WriteExplanation(checked, path)
|
||||
if checked.failure != nil {
|
||||
if renderErr == nil {
|
||||
renderErr = checked.failure
|
||||
} else {
|
||||
renderErr = errors.Join(renderErr, checked.failure)
|
||||
}
|
||||
}
|
||||
if renderErr != nil {
|
||||
outcome := configOperationFailure(renderErr, diagnostics, true)
|
||||
outcome.DeliveryError = checked.failure
|
||||
return outcome
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
return report.Bytes(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -8,9 +9,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
type scopeMetadata struct {
|
||||
@@ -260,24 +260,23 @@ func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit.
|
||||
Name: "scopes",
|
||||
Summary: "List configuration scopes and supported operations.",
|
||||
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 },
|
||||
)},
|
||||
CheckInput: func(input scopeInventoryInput) error { return checkConfigFormat(input.Format) },
|
||||
Run: func(invocation commandkit.Invocation, input scopeInventoryInput) commandkit.Outcome {
|
||||
var output []byte
|
||||
var err error
|
||||
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||
var report bytes.Buffer
|
||||
if input.Format == "yaml" {
|
||||
output, err = yaml.Marshal(prepared.inventory)
|
||||
if err := encodeYAML(&report, prepared.inventory); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
output = []byte(formatScopeInventory(prepared.inventory))
|
||||
}
|
||||
if err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, output); err != nil {
|
||||
return incompleteConfigReport(err)
|
||||
_, _ = report.WriteString(formatScopeInventory(prepared.inventory))
|
||||
}
|
||||
_, err := writer.Write(report.Bytes())
|
||||
return err
|
||||
})
|
||||
return commandkit.Success()
|
||||
},
|
||||
}
|
||||
@@ -314,21 +313,3 @@ func checkConfigFormat(format string) error {
|
||||
}
|
||||
|
||||
func parseConfigString(value string) (string, error) { return value, nil }
|
||||
|
||||
func writeConfigReport(writer io.Writer, content []byte) error {
|
||||
written, err := writer.Write(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written != len(content) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func incompleteConfigReport(err error) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(err)
|
||||
outcome.ReportIncomplete = true
|
||||
outcome.DeliveryError = err
|
||||
return outcome
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
func TestScopeDiscoveryIsOrderedAndDoesNotUseSuiteFactories(t *testing.T) {
|
||||
@@ -161,7 +161,7 @@ func TestValidationPreparesIndependentScopeInputsAndOneSharedState(t *testing.T)
|
||||
}
|
||||
app := newConfigTestApp(t, Command(suite))
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--config", "chosen.yaml", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--config", "chosen.yaml", "--campaign", "spring", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(diagnostic.String(), "app warning") || !strings.Contains(diagnostic.String(), "promptkit warning") {
|
||||
t.Fatalf("validation outcome=%#v diagnostics=%q", outcome, diagnostic.String())
|
||||
}
|
||||
@@ -225,6 +225,51 @@ func TestRequiredSelectorAppliesOnlyToItsSelectedScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigAliasesForwardSelectedTenantAndGlobalHelp(t *testing.T) {
|
||||
type input struct{ Tenant commandkit.Supplied[string] }
|
||||
flag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant"}, (*string)(nil),
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(target *input, value commandkit.Supplied[string]) { target.Tenant = value })
|
||||
var acquired []commandkit.Supplied[string]
|
||||
command := Command(Suite[input, struct{}]{
|
||||
Selectors: []Selector[input]{{Binding: flag, Scopes: []string{"promptkit"}}},
|
||||
Scopes: []Scope[input, struct{}]{
|
||||
{
|
||||
Name: "promptkit",
|
||||
Operations: []Operation{Validate},
|
||||
Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) {
|
||||
acquired = append(acquired, value.Tenant)
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"},
|
||||
command,
|
||||
commandkit.Alias("check-config", "config validate"),
|
||||
commandkit.FlagAliasFor("config validate", "--tenant-id", "--tenant"),
|
||||
commandkit.FlagAlias("--hlep", "--help"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"check-config", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "validate") || len(acquired) != 0 {
|
||||
t.Fatalf("global help alias outcome/output/acquisitions = (%#v, %q, %#v)", outcome, output.String(), acquired)
|
||||
}
|
||||
|
||||
outcome = app.Execute(context.Background(), []string{"check-config", "--scope", "promptkit", "--tenant-id=acme"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || len(acquired) != 1 || acquired[0] != (commandkit.Supplied[string]{Value: "acme", Set: true}) {
|
||||
t.Fatalf("aliased selector outcome/acquisitions = (%#v, %#v)", outcome, acquired)
|
||||
}
|
||||
outcome = app.Execute(context.Background(), []string{"config", "validate", "--scope", "promptkit", "--tenant=canonical"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || len(acquired) != 2 || acquired[1] != (commandkit.Supplied[string]{Value: "canonical", Set: true}) {
|
||||
t.Fatalf("canonical selector outcome/acquisitions = (%#v, %#v)", outcome, acquired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
var acquisitionOutputLengths []int
|
||||
@@ -244,7 +289,7 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
|
||||
makeScope("promptkit", "---\nname: promptkit\n", providerErr),
|
||||
}}))
|
||||
outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr || output.Len() != 0 {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr || output.Len() != 0 || outcome.Presentation.Output.State != commandkit.DeliveryNone {
|
||||
t.Fatalf("aggregate provider failure outcome=%#v output=%q", outcome, output.String())
|
||||
}
|
||||
if len(acquisitionOutputLengths) != 2 || acquisitionOutputLengths[0] != 0 || acquisitionOutputLengths[1] != 0 {
|
||||
@@ -258,7 +303,7 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
|
||||
makeScope("promptkit", "name: promptkit\n", nil),
|
||||
}}))
|
||||
outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliveryComplete {
|
||||
t.Fatalf("aggregate show outcome = %#v", outcome)
|
||||
}
|
||||
want := "---\n# scope: app\nname: app\n---\n# scope: promptkit\nname: promptkit\n"
|
||||
@@ -267,6 +312,109 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowSeparatesWarningsFromTerminalContextAcrossModes(t *testing.T) {
|
||||
providerErr := errors.New("private provider cause")
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{
|
||||
Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent warning")},
|
||||
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "native terminal context\n")
|
||||
return err
|
||||
}}},
|
||||
}, providerErr
|
||||
},
|
||||
}}}))
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
flags []string
|
||||
wantWarning bool
|
||||
wantTerminal bool
|
||||
}{
|
||||
{name: "ordinary", wantTerminal: true},
|
||||
{name: "verbose", flags: []string{"--verbose"}, wantWarning: true, wantTerminal: true},
|
||||
{name: "quiet", flags: []string{"--quiet"}, wantTerminal: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := append([]string{"config", "show"}, test.flags...)
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr {
|
||||
t.Fatalf("provider failure outcome = %#v", outcome)
|
||||
}
|
||||
if got := strings.Contains(diagnostic.String(), "independent warning"); got != test.wantWarning {
|
||||
t.Fatalf("warning visibility = %v, output %q", got, diagnostic.String())
|
||||
}
|
||||
if got := strings.Contains(diagnostic.String(), "native terminal context"); got != test.wantTerminal {
|
||||
t.Fatalf("terminal context visibility = %v, output %q", got, diagnostic.String())
|
||||
}
|
||||
if strings.Contains(diagnostic.String(), providerErr.Error()) {
|
||||
t.Fatalf("cause text was duplicated into presentation: %q", diagnostic.String())
|
||||
}
|
||||
if test.wantWarning && strings.Index(diagnostic.String(), "independent warning") > strings.Index(diagnostic.String(), "native terminal context") {
|
||||
t.Fatalf("deferred warning did not precede terminal context: %q", diagnostic.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowAndValidationUseManagedRendering(t *testing.T) {
|
||||
t.Run("quiet show still records native render errors", func(t *testing.T) {
|
||||
renderErr := errors.New("native renderer failed")
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Effective: failedEffective{err: renderErr}}}, nil
|
||||
},
|
||||
}}}))
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "show", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || outcome.Cause != nil || output.Len() != 0 {
|
||||
t.Fatalf("quiet render failure outcome=%#v output=%q", outcome, output.String())
|
||||
}
|
||||
})
|
||||
|
||||
for _, operation := range []Operation{Show, Validate} {
|
||||
t.Run(string(operation)+" write failure", func(t *testing.T) {
|
||||
writeErr := errors.New("report destination failed")
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app",
|
||||
Operations: []Operation{operation},
|
||||
Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("value: ready\n")}}, nil
|
||||
},
|
||||
}}}))
|
||||
commandName := "show"
|
||||
if operation == Validate {
|
||||
commandName = "validate"
|
||||
}
|
||||
outcome := app.Execute(context.Background(), []string{"config", commandName}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Cause != nil {
|
||||
t.Fatalf("report write failure outcome = %#v", outcome)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(string(operation)+" quiet suppression", func(t *testing.T) {
|
||||
writeErr := errors.New("quiet destination should not be used")
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app",
|
||||
Operations: []Operation{operation},
|
||||
Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("value: ready\n")}}, nil
|
||||
},
|
||||
}}}))
|
||||
commandName := "show"
|
||||
if operation == Validate {
|
||||
commandName = "validate"
|
||||
}
|
||||
outcome := app.Execute(context.Background(), []string{"config", commandName, "--quiet"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliverySuppressed || outcome.Presentation.Output.WriteError != nil || outcome.Cause != nil {
|
||||
t.Fatalf("quiet report outcome = %#v", outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesReadsNativeInventoryOnceAndPreservesZeroValues(t *testing.T) {
|
||||
reader := &sourceInventory{values: []configkit.SourceInfo{{
|
||||
ID: "synthetic", Label: "", Path: "", Layer: configkit.Layer("base"), Kind: configkit.SourceKind("mapping"), UnderlayIndex: 0,
|
||||
@@ -293,6 +441,7 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
|
||||
value: configkit.Explanation{
|
||||
Path: configkit.Path{},
|
||||
Effective: configkit.ValueSnapshot{Present: true, Redacted: true},
|
||||
Origin: configkit.Origin{Layer: configkit.LayerMain},
|
||||
History: []configkit.ValueChange{{
|
||||
Before: 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() != "" {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Sources: &sourceInventory{values: []configkit.SourceInfo{}}}}, nil
|
||||
}}}}))
|
||||
outcome := app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
|
||||
if !errors.Is(outcome.Cause, writeErr) || outcome.DeliveryError != writeErr || !outcome.ReportIncomplete {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("source delivery failure outcome = %#v", outcome)
|
||||
}
|
||||
|
||||
@@ -349,7 +513,7 @@ func TestReportWriteFailureTracksIncompleteDeliveryAndCancellation(t *testing.T)
|
||||
}}))
|
||||
var output bytes.Buffer
|
||||
outcome = app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if !errors.Is(outcome.Cause, context.Canceled) || !outcome.ReportIncomplete || calls != 2 || output.String() != "app: valid\n" {
|
||||
if !errors.Is(outcome.Cause, context.Canceled) || outcome.Presentation.Output.State != commandkit.DeliveryComplete || calls != 2 || output.String() != "app: valid\n" {
|
||||
t.Fatalf("cancelled aggregate validation outcome=%#v calls=%d output=%q", outcome, calls, output.String())
|
||||
}
|
||||
}
|
||||
@@ -361,6 +525,12 @@ func (value effectiveValue) WriteEffective(writer io.Writer) error {
|
||||
return err
|
||||
}
|
||||
|
||||
type failedEffective struct{ err error }
|
||||
|
||||
func (value failedEffective) WriteEffective(io.Writer) error {
|
||||
return value.err
|
||||
}
|
||||
|
||||
type sourceInventory struct {
|
||||
values []configkit.SourceInfo
|
||||
calls int
|
||||
@@ -377,6 +547,19 @@ type nativeExplanation struct {
|
||||
found bool
|
||||
}
|
||||
|
||||
type failingNativeExplanation struct{ err error }
|
||||
|
||||
func (reader *failingNativeExplanation) Explain(configkit.Path) (configkit.Explanation, bool) {
|
||||
return configkit.Explanation{}, true
|
||||
}
|
||||
|
||||
func (reader *failingNativeExplanation) WriteExplanation(writer io.Writer, _ configkit.Path) error {
|
||||
if _, err := io.WriteString(writer, "partial native explanation\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return reader.err
|
||||
}
|
||||
|
||||
func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
|
||||
reader.path = path
|
||||
return reader.value, reader.found
|
||||
|
||||
@@ -1,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 (
|
||||
gitea.maximumdirect.net/eric/CommandKit v0.2.0
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0
|
||||
go.maximumdirect.net/commandkit v0.3.0
|
||||
go.maximumdirect.net/configkit v1.1.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
gitea.maximumdirect.net/eric/CommandKit v0.2.0 h1:UNBjSq6XDZHdPqvZLOL3N5R1E1ol0jnfldgradU7eqw=
|
||||
gitea.maximumdirect.net/eric/CommandKit v0.2.0/go.mod h1:47DQ6Mo/yZqUVn5PAFRmPck91Zhf1jfaHDMoEZvUzsQ=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
@@ -10,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/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
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/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
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 {
|
||||
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]]{
|
||||
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 }),
|
||||
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 }),
|
||||
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]) {
|
||||
(*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]) {
|
||||
(*input).selection.Overlay = value
|
||||
}),
|
||||
}
|
||||
if operation == Diff {
|
||||
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]) {
|
||||
(*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]) {
|
||||
(*input).against.Overlay = value
|
||||
}),
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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 {
|
||||
if input == nil || input.plan == nil {
|
||||
return commandkit.Failure(errors.New("configuration operation was not prepared"))
|
||||
return commandkit.Failure("Configuration operation was not prepared.")
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
return configFailure("Configuration operation was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
plan := input.plan
|
||||
var state S
|
||||
@@ -31,19 +31,70 @@ func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Op
|
||||
case Diff:
|
||||
return runConfigDiff(invocation, plan, state)
|
||||
default:
|
||||
return commandkit.Failure(fmt.Errorf("unsupported configuration operation %q", operation))
|
||||
return commandkit.Failure(fmt.Sprintf("Unsupported configuration operation %q.", operation))
|
||||
}
|
||||
}
|
||||
|
||||
func configOperationFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(cause)
|
||||
outcome.Diagnostics = diagnostics
|
||||
outcome.ReportIncomplete = reportStarted
|
||||
func queueConfigDiagnostics(reporter *commandkit.Reporter, diagnostics []commandkit.Diagnostic) {
|
||||
deferred := reporter.AtEnd()
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Write == nil {
|
||||
continue
|
||||
}
|
||||
switch diagnostic.Severity {
|
||||
case commandkit.SeverityInfo:
|
||||
_ = deferred.Info("", diagnostic.Write)
|
||||
case commandkit.SeverityDebug:
|
||||
_ = deferred.Debug("", diagnostic.Write)
|
||||
case commandkit.SeverityProgress:
|
||||
_ = reporter.Progress("", diagnostic.Write)
|
||||
default:
|
||||
_ = deferred.Warn("", diagnostic.Write)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func configFailure(message string, explanation commandkit.Explanation, cause error) commandkit.Outcome {
|
||||
if explanation.Message != "" || len(explanation.Details) > 0 {
|
||||
message = explanation.Message
|
||||
}
|
||||
outcome := commandkit.Failure(message, explanation.Details...)
|
||||
if cause != nil {
|
||||
outcome = outcome.WithCause(cause)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
func configDeliveryFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome {
|
||||
outcome := configOperationFailure(cause, diagnostics, reportStarted)
|
||||
outcome.DeliveryError = cause
|
||||
return outcome
|
||||
func configAcquisitionFailure(
|
||||
reporter *commandkit.Reporter,
|
||||
operation, scope string,
|
||||
acquired commandkit.Acquired[View],
|
||||
cause error,
|
||||
) commandkit.Outcome {
|
||||
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||
message := fmt.Sprintf("Unable to %s configuration scope %q.", operation, scope)
|
||||
return configFailure(message, acquired.Explanation, cause)
|
||||
}
|
||||
|
||||
// Preserve the preparation/delivery cancellation checkpoint for buffered reports.
|
||||
func renderPreparedConfigReport(invocation commandkit.Invocation, explanation commandkit.Explanation, prepare func() ([]byte, error)) commandkit.Outcome {
|
||||
var canceled error
|
||||
renderErr := invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||
report, err := prepare()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if canceled = invocation.Context.Err(); canceled != nil {
|
||||
return nil
|
||||
}
|
||||
_, err = writer.Write(report)
|
||||
return err
|
||||
})
|
||||
if canceled == nil && renderErr != nil {
|
||||
canceled = invocation.Context.Err()
|
||||
}
|
||||
if canceled != nil {
|
||||
return configFailure("Configuration report preparation was canceled.", explanation, canceled)
|
||||
}
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
@@ -2,12 +2,26 @@
|
||||
"product": "CommandKit ckconfig",
|
||||
"module_dir": "integrations/ckconfig",
|
||||
"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": {
|
||||
"shared_files": [
|
||||
"COPYRIGHT",
|
||||
"LICENSE",
|
||||
"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 (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"unicode"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
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 {
|
||||
documents := make([]effectiveDocument, 0, len(plan.scopes))
|
||||
diagnostics := make([]commandkit.Diagnostic, 0)
|
||||
reporter := invocation.Reporter()
|
||||
for _, selected := range plan.scopes {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
if selected.scope.scope.Acquire == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", selected.scope.scope.Name), diagnostics, false)
|
||||
message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name)
|
||||
return commandkit.Failure(message)
|
||||
}
|
||||
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||
Operation: Show,
|
||||
Selection: plan.selection,
|
||||
}, selected.input, state)
|
||||
diagnostics = append(diagnostics, acquired.Diagnostics...)
|
||||
if acquireErr != nil {
|
||||
return configOperationFailure(acquireErr, diagnostics, false)
|
||||
return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr)
|
||||
}
|
||||
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration show was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
if acquired.Value.Effective == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide an effective configuration writer", selected.scope.scope.Name), diagnostics, false)
|
||||
message := fmt.Sprintf("Configuration scope %q did not provide an effective configuration writer.", selected.scope.scope.Name)
|
||||
return commandkit.Failure(message)
|
||||
}
|
||||
documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value})
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
if len(documents) == 1 && plan.scopeName != "all" {
|
||||
checked := &checkedWriter{writer: invocation.Streams.Out}
|
||||
renderErr := documents[0].view.Effective.WriteEffective(checked)
|
||||
if checked.failure != nil {
|
||||
if renderErr == nil {
|
||||
renderErr = checked.failure
|
||||
} else {
|
||||
renderErr = errors.Join(renderErr, checked.failure)
|
||||
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||
return documents[0].view.Effective.WriteEffective(writer)
|
||||
})
|
||||
if err != nil && invocation.Context.Err() != nil {
|
||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
|
||||
}
|
||||
}
|
||||
if renderErr != nil {
|
||||
outcome := configOperationFailure(renderErr, diagnostics, true)
|
||||
outcome.DeliveryError = checked.failure
|
||||
return outcome
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
written := false
|
||||
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||
for index, document := range documents {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, written)
|
||||
return err
|
||||
}
|
||||
var native bytes.Buffer
|
||||
if err := document.view.Effective.WriteEffective(&native); err != nil {
|
||||
return configOperationFailure(err, diagnostics, written)
|
||||
return err
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, written)
|
||||
return err
|
||||
}
|
||||
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 _, 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 := writeConfigReport(invocation.Streams.Out, framed); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
if _, err := writer.Write(framed); err != nil {
|
||||
return err
|
||||
}
|
||||
written = true
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
return nil
|
||||
})
|
||||
if err != nil && invocation.Context.Err() != nil {
|
||||
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err())
|
||||
}
|
||||
|
||||
type checkedWriter struct {
|
||||
writer io.Writer
|
||||
failure error
|
||||
}
|
||||
|
||||
func (writer *checkedWriter) Write(content []byte) (int, error) {
|
||||
if writer.failure != nil {
|
||||
return 0, writer.failure
|
||||
}
|
||||
written, err := writer.writer.Write(content)
|
||||
if err == nil && written != len(content) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil {
|
||||
writer.failure = err
|
||||
}
|
||||
return written, err
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
type yamlPreamble struct {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -22,12 +22,19 @@ type sourceDTO struct {
|
||||
Path string `yaml:"path"`
|
||||
Destination string `yaml:"destination"`
|
||||
Placement string `yaml:"placement"`
|
||||
DestinationUnresolved bool `yaml:"destination_unresolved"`
|
||||
DestinationIsFallback bool `yaml:"destination_is_fallback"`
|
||||
Layer string `yaml:"layer"`
|
||||
Kind string `yaml:"kind"`
|
||||
UnderlayIndex int `yaml:"underlay_index"`
|
||||
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 {
|
||||
@@ -36,7 +43,7 @@ type locationDTO struct {
|
||||
Column int `yaml:"column"`
|
||||
}
|
||||
|
||||
type sourceVersionCheckDTO struct {
|
||||
type sourceVersionEvidenceDTO struct {
|
||||
Path string `yaml:"path"`
|
||||
Version versionDTO `yaml:"version"`
|
||||
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 {
|
||||
selected := plan.scopes[0]
|
||||
name := selected.scope.scope.Name
|
||||
reporter := invocation.Reporter()
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, nil, false)
|
||||
return configFailure("Configuration source listing was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
if selected.scope.scope.Acquire == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false)
|
||||
cause := fmt.Errorf("configuration scope %q has no acquisition callback", name)
|
||||
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), commandkit.Explanation{}, cause)
|
||||
}
|
||||
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||
Operation: Sources,
|
||||
Selection: plan.selection,
|
||||
}, selected.input, state)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
|
||||
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||
if acquireErr != nil {
|
||||
return configOperationFailure(acquireErr, diagnostics, false)
|
||||
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), acquired.Explanation, acquireErr)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
if acquired.Value.Sources == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide a source reader", name), diagnostics, false)
|
||||
cause := fmt.Errorf("configuration scope %q did not provide a source reader", name)
|
||||
return configFailure("Configuration scope did not provide source information.", acquired.Explanation, cause)
|
||||
}
|
||||
|
||||
sources := acquired.Value.Sources.Sources()
|
||||
@@ -86,17 +96,18 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
|
||||
Path: source.Path,
|
||||
Destination: source.Destination.String(),
|
||||
Placement: placement,
|
||||
DestinationUnresolved: source.DestinationUnresolved,
|
||||
DestinationIsFallback: source.DestinationIsFallback,
|
||||
Layer: string(source.Layer),
|
||||
Kind: string(source.Kind),
|
||||
UnderlayIndex: source.UnderlayIndex,
|
||||
EmptyImportSelections: make([]emptyImportSelectionDTO, 0, len(source.EmptyImportSelections)),
|
||||
}
|
||||
if source.ImportedFrom != nil {
|
||||
item.ImportedFrom = &locationDTO{SourceID: source.ImportedFrom.SourceID, Line: source.ImportedFrom.Line, Column: source.ImportedFrom.Column}
|
||||
}
|
||||
if source.VersionCheck != nil {
|
||||
check := source.VersionCheck
|
||||
item.VersionCheck = &sourceVersionCheckDTO{
|
||||
if source.VersionEvidence != nil {
|
||||
check := source.VersionEvidence
|
||||
item.VersionEvidence = &sourceVersionEvidenceDTO{
|
||||
Path: check.Path.String(),
|
||||
Version: versionDTO{
|
||||
Present: check.Version.Present,
|
||||
@@ -106,28 +117,28 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
|
||||
Redacted: check.Redacted,
|
||||
}
|
||||
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)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||
var report bytes.Buffer
|
||||
if plan.format == "yaml" {
|
||||
if err := encodeYAML(&report, document); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
writeSourceReportText(&report, document)
|
||||
}
|
||||
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
|
||||
return report.Bytes(), nil
|
||||
})
|
||||
}
|
||||
|
||||
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, " destination: %s\n", strconv.Quote(source.Destination))
|
||||
_, _ = 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, " kind: %s\n", strconv.Quote(source.Kind))
|
||||
_, _ = fmt.Fprintf(writer, " underlay_index: %d\n", source.UnderlayIndex)
|
||||
@@ -153,12 +164,11 @@ func writeSourceReportText(writer *bytes.Buffer, document sourceReport) {
|
||||
_, _ = fmt.Fprintf(writer, " imported_from: {source_id: %s, line: %d, column: %d}\n",
|
||||
strconv.Quote(source.ImportedFrom.SourceID), source.ImportedFrom.Line, source.ImportedFrom.Column)
|
||||
}
|
||||
if source.VersionCheck == nil {
|
||||
_, _ = writer.WriteString(" version_check: null\n")
|
||||
continue
|
||||
}
|
||||
check := source.VersionCheck
|
||||
_, _ = fmt.Fprintf(writer, " version_check:\n path: %s\n", strconv.Quote(check.Path))
|
||||
if source.VersionEvidence == nil {
|
||||
_, _ = writer.WriteString(" version_evidence: null\n")
|
||||
} else {
|
||||
check := source.VersionEvidence
|
||||
_, _ = 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 {
|
||||
@@ -169,6 +179,15 @@ func writeSourceReportText(writer *bytes.Buffer, document sourceReport) {
|
||||
}
|
||||
_, _ = 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func encodeYAML(writer io.Writer, document any) error {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -34,13 +34,17 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
||||
reader := &sourceInventory{values: []configkit.SourceInfo{
|
||||
{
|
||||
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",
|
||||
Destination: servicePath, PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
DestinationUnresolved: true,
|
||||
VersionCheck: &configkit.SourceVersionCheck{
|
||||
DestinationIsFallback: true,
|
||||
VersionEvidence: &configkit.SourceVersionEvidence{
|
||||
Path: versionPath,
|
||||
Version: configkit.Version{Present: true, Text: "v2"},
|
||||
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",
|
||||
Destination: otherPath, PlacementMode: configkit.PlacementModeMapping,
|
||||
VersionCheck: &configkit.SourceVersionCheck{
|
||||
VersionEvidence: &configkit.SourceVersionEvidence{
|
||||
Path: nullVersionPath,
|
||||
Version: configkit.Version{Present: true, Null: true},
|
||||
Redacted: true,
|
||||
@@ -73,8 +77,10 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
||||
ID string `yaml:"id"`
|
||||
Destination string `yaml:"destination"`
|
||||
Placement string `yaml:"placement"`
|
||||
DestinationUnresolved bool `yaml:"destination_unresolved"`
|
||||
VersionCheck *struct {
|
||||
Layer string `yaml:"layer"`
|
||||
EmptyImportSelections []emptyImportSelectionDTO `yaml:"empty_import_selections"`
|
||||
DestinationIsFallback bool `yaml:"destination_is_fallback"`
|
||||
VersionEvidence *struct {
|
||||
Path string `yaml:"path"`
|
||||
Version struct {
|
||||
Present bool `yaml:"present"`
|
||||
@@ -87,7 +93,7 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
||||
Column int `yaml:"column"`
|
||||
} `yaml:"location"`
|
||||
Redacted bool `yaml:"redacted"`
|
||||
} `yaml:"version_check"`
|
||||
} `yaml:"version_evidence"`
|
||||
} `yaml:"sources"`
|
||||
}
|
||||
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"}) {
|
||||
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)
|
||||
}
|
||||
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]
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -122,9 +138,15 @@ func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || reader.calls != 2 {
|
||||
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{
|
||||
`id: "root"`, `destination: ""`, `placement: "mapping"`, "destination_unresolved: false", "version_check: null",
|
||||
`destination: "/services"`, `placement: "sequence_item"`, "destination_unresolved: true", `path: "/version"`, `text: "v2"`,
|
||||
`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_is_fallback: true", `path: "/version"`, `text: "v2"`,
|
||||
`destination: "/other"`, `path: "/metadata/version"`, "null: true", `text: ""`, "location: null", "redacted: true",
|
||||
} {
|
||||
if !strings.Contains(textOutput.String(), fragment) {
|
||||
|
||||
@@ -3,51 +3,58 @@ package ckconfig
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||
diagnostics := make([]commandkit.Diagnostic, 0)
|
||||
reporter := invocation.Reporter()
|
||||
writtenRecord := false
|
||||
for _, selected := range plan.scopes {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, writtenRecord)
|
||||
return configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
if selected.scope.scope.Acquire == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", selected.scope.scope.Name), diagnostics, writtenRecord)
|
||||
message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name)
|
||||
return commandkit.Failure(message)
|
||||
}
|
||||
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||
Operation: Validate,
|
||||
Selection: plan.selection,
|
||||
}, selected.input, state)
|
||||
diagnostics = append(diagnostics, acquired.Diagnostics...)
|
||||
if acquireErr != nil {
|
||||
return configOperationFailure(acquireErr, diagnostics, writtenRecord)
|
||||
return configAcquisitionFailure(reporter, "validate", selected.scope.scope.Name, acquired, acquireErr)
|
||||
}
|
||||
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, writtenRecord)
|
||||
return configFailure("Configuration validation was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
|
||||
err := reporter.RenderOutput(func(writer io.Writer) error {
|
||||
record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format)
|
||||
if err != nil {
|
||||
return configOperationFailure(err, diagnostics, writtenRecord)
|
||||
return err
|
||||
}
|
||||
if plan.format == "yaml" && !writtenRecord {
|
||||
if err := writeConfigReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
if _, err := io.WriteString(writer, "scopes:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, record); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
_, err = writer.Write(record)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if ctxErr := invocation.Context.Err(); ctxErr != nil {
|
||||
return configFailure("Configuration validation was canceled.", acquired.Explanation, ctxErr)
|
||||
}
|
||||
return commandkit.Success()
|
||||
}
|
||||
writtenRecord = true
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
func validationSuccessRecord(scope, format string) ([]byte, error) {
|
||||
|
||||
@@ -7,10 +7,9 @@ Importing it does not install either command family.
|
||||
|
||||
## Module
|
||||
|
||||
The module path is
|
||||
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt`. Its first
|
||||
release is prepared as `v0.1.0`. It uses `commandkit`, `ckconfig`, and
|
||||
PromptKit. See the
|
||||
The module path is `go.maximumdirect.net/ckprompt`. Version v0.2.0 uses
|
||||
`go.maximumdirect.net/commandkit v0.3.0`, `go.maximumdirect.net/ckconfig v0.2.0`,
|
||||
and PromptKit v0.16.0. See the
|
||||
[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.
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/ckconfig"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
// PromptRequest selects one named prompt for inspection.
|
||||
@@ -88,26 +88,33 @@ type Suite[I, S any] struct {
|
||||
// CheckInput validates selected inspection, requirements, validation, smoke,
|
||||
// or catalog input before state creation.
|
||||
CheckInput func(ckconfig.Selection, I) error
|
||||
// InspectPrompt inspects the selected prompt after input and state preparation.
|
||||
// InspectPrompt inspects the selected prompt after input and state
|
||||
// preparation. Its acquisition carries independent loader diagnostics and
|
||||
// terminal context alongside any native inspection result.
|
||||
InspectPrompt func(context.Context, PromptRequest, I, S) (commandkit.Acquired[*promptkit.PromptInspection], error)
|
||||
// InspectProfile inspects the selected profile after input and state preparation.
|
||||
// InspectProfile inspects the selected profile after input and state
|
||||
// preparation. Its acquisition carries independent loader diagnostics and
|
||||
// terminal context alongside any native inspection result.
|
||||
InspectProfile func(context.Context, ProfileRequest, I, S) (commandkit.Acquired[*promptkit.ProfileInspection], error)
|
||||
// InspectRequirements inspects native PromptKit requirements after input and
|
||||
// state preparation. It is optional; a nil callback omits
|
||||
// `promptkit requirements show` without disabling validation or other
|
||||
// capabilities.
|
||||
// state preparation. Its acquisition carries independent loader diagnostics
|
||||
// and terminal context alongside any native inspection result. It is
|
||||
// optional; a nil callback omits `promptkit requirements show` without
|
||||
// disabling validation or other capabilities.
|
||||
InspectRequirements func(context.Context, RequirementsRequest, I, S) (commandkit.Acquired[*promptkit.RequirementInspection], error)
|
||||
// ValidateRequest validates a concrete PromptKit request after input and
|
||||
// state preparation. It is optional; a nil callback omits
|
||||
// `promptkit requirements validate` without disabling discovery or other
|
||||
// capabilities. The acquisition carries diagnostics and an empty value on
|
||||
// success; PromptKit errors are returned unchanged where supported.
|
||||
// capabilities. The acquisition carries independent diagnostics, terminal
|
||||
// context, and an empty value on success; PromptKit errors are returned
|
||||
// unchanged where supported.
|
||||
ValidateRequest func(context.Context, ValidationRequest, I, S) (commandkit.Acquired[struct{}], error)
|
||||
// SmokeTest runs one concrete native smoke-test operation after input and
|
||||
// state preparation. It is optional; nil omits only the smoke-test command.
|
||||
// The acquisition retains a native report even when the callback also
|
||||
// returns an error. For `test all`, CommandKit calls the captured callback
|
||||
// sequentially for each selected kind after preparing input and state once.
|
||||
// The acquisition retains a native report, independent diagnostics, and
|
||||
// terminal context even when the callback also returns an error. For
|
||||
// `test all`, CommandKit calls the captured callback sequentially for each
|
||||
// selected kind after preparing input and state once.
|
||||
SmokeTest func(context.Context, SmokeRequest, I, S) (commandkit.Acquired[*promptkit.SmokeTestReport], error)
|
||||
// ListPrompts lists prompt identities after input and state preparation.
|
||||
// It is optional independently of the other catalogs; nil omits only this
|
||||
@@ -142,10 +149,12 @@ type Suite[I, S any] struct {
|
||||
// PromptKit unchanged; the loader receives the seed's original configuration
|
||||
// selection. A nil builder forwards the seed request unchanged. A builder
|
||||
// failure prevents engine loading and preserves its supported error identity.
|
||||
// Native validation errors are returned with supported identity, and
|
||||
// configuration diagnostics from engine loading remain in the acquisition. A
|
||||
// nil loader is retained as a suite wiring issue and does not install a
|
||||
// validation callback. Callback references and objects remain caller-owned.
|
||||
// Native validation errors supply terminal explanations and are returned with
|
||||
// supported identity, and
|
||||
// configuration diagnostics and terminal context from engine loading remain
|
||||
// separate in the acquisition. A nil loader is retained as a suite wiring
|
||||
// issue and does not install a validation callback. Callback references and
|
||||
// objects remain caller-owned.
|
||||
func WithRequestValidation[I, S any](
|
||||
suite Suite[I, S],
|
||||
load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error),
|
||||
@@ -175,34 +184,56 @@ func WithRequestValidation[I, S any](
|
||||
return commandkit.Acquired[struct{}]{}, fmt.Errorf("PromptKit request validation after building: %w", err)
|
||||
}
|
||||
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit request validation before native validation: %w", err)
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit request validation before native validation: %w", err)
|
||||
}
|
||||
|
||||
if err := engine.ValidateRequest(ctx, concreteRequest); err != nil {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit request validation after native validation: %w", err)
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit request validation after native validation: %w", err)
|
||||
}
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, nil
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, nil
|
||||
}
|
||||
return suite
|
||||
}
|
||||
|
||||
type engineLoader[I, S any] func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)
|
||||
|
||||
type promptKitAcquisition struct {
|
||||
diagnostics []commandkit.Diagnostic
|
||||
explanation commandkit.Explanation
|
||||
}
|
||||
|
||||
// withOperationError deliberately presents a native PromptKit operation error.
|
||||
// Loader and request-builder errors remain application-owned. Native structured
|
||||
// context takes precedence when an acquisition already supplies it.
|
||||
func (messages promptKitAcquisition) withOperationError(err error) promptKitAcquisition {
|
||||
if err == nil || messages.explanation.Message != "" {
|
||||
return messages
|
||||
}
|
||||
for _, detail := range messages.explanation.Details {
|
||||
if detail != nil {
|
||||
return messages
|
||||
}
|
||||
}
|
||||
messages.explanation.Message = err.Error()
|
||||
return messages
|
||||
}
|
||||
|
||||
// ConfigScope adapts PromptKit's captured configuration inspection to the
|
||||
// standard configuration commands, including native comparison when the scope
|
||||
// declares Diff. The loader is not called during declaration construction or
|
||||
// installation; it is called only when a config operation selects the
|
||||
// promptkit scope, with that operation's input and shared state. PromptKit
|
||||
// diagnostics and supported error identity are returned through ckconfig's
|
||||
// acquisition and comparison contracts.
|
||||
// independent diagnostics, terminal context, and supported error identity are
|
||||
// returned through ckconfig's acquisition and comparison contracts.
|
||||
func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) ckconfig.Scope[I, S] {
|
||||
scope := ckconfig.Scope[I, S]{
|
||||
Name: "promptkit",
|
||||
@@ -214,9 +245,9 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
|
||||
}
|
||||
|
||||
scope.Acquire = func(ctx context.Context, request ckconfig.Request, input I, state S) (commandkit.Acquired[ckconfig.View], error) {
|
||||
_, inspection, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
_, inspection, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[ckconfig.View]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[ckconfig.View]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
return commandkit.Acquired[ckconfig.View]{
|
||||
Value: ckconfig.View{
|
||||
@@ -224,7 +255,8 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
|
||||
Sources: inspection,
|
||||
Explanation: inspection,
|
||||
},
|
||||
Diagnostics: diagnostics,
|
||||
Diagnostics: messages.diagnostics,
|
||||
Explanation: messages.explanation,
|
||||
}, nil
|
||||
}
|
||||
scope.Compare = func(ctx context.Context, request ckconfig.ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
@@ -232,31 +264,31 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S)
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("PromptKit scope %q comparison before left load: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
_, leftInspection, leftDiagnostics, err := loadPromptKitEngine(ctx, request.Left.Selection, request.Left.Input, state, "left", load)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
|
||||
_, leftInspection, messages, err := loadPromptKitEngine(ctx, request.Left.Selection, request.Left.Input, state, "left", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q left comparison load: %w", scope.Name, err)
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q left comparison load: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q comparison after left load: %w", scope.Name, err)
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q comparison after left load: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
_, rightInspection, rightDiagnostics, err := loadPromptKitEngine(ctx, request.Right.Selection, request.Right.Input, state, "right", load)
|
||||
diagnostics = append(diagnostics, rightDiagnostics...)
|
||||
_, rightInspection, rightMessages, err := loadPromptKitEngine(ctx, request.Right.Selection, request.Right.Input, state, "right", load)
|
||||
messages = appendPromptKitAcquisition(messages, rightMessages)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q right comparison load: %w", scope.Name, err)
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q right comparison load: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q comparison before native diff: %w", scope.Name, err)
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q comparison before native diff: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
differences, err := leftInspection.Diff(rightInspection)
|
||||
messages = messages.withOperationError(err)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err)
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err)
|
||||
}
|
||||
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil
|
||||
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, nil
|
||||
}
|
||||
return scope
|
||||
}
|
||||
@@ -280,8 +312,9 @@ func promptKitContextError(ctx context.Context) error {
|
||||
// shared invocation state to the loader before calling PromptKit's public
|
||||
// listing or inspection method. Applications can narrow, wrap, or replace these
|
||||
// callbacks before Command captures them. A nil loader is retained as an
|
||||
// adapter issue. Each callback returns loader diagnostics with the native
|
||||
// operation's value and error.
|
||||
// adapter issue. Each callback returns independent loader diagnostics and
|
||||
// terminal context alongside the native operation's value and error. Native
|
||||
// operation errors supply terminal explanations when structured context is absent.
|
||||
func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) Suite[I, S] {
|
||||
if load == nil {
|
||||
return Suite[I, S]{issues: []error{errors.New("PromptKit engine loader is nil")}}
|
||||
@@ -289,68 +322,76 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) (
|
||||
|
||||
return Suite[I, S]{
|
||||
ListPrompts: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
prompts, err := engine.ListPrompts(ctx, request.Filter)
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: prompts, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: prompts, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
ListProfiles: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]string], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
profiles, err := engine.ListProfiles(ctx, request.Filter)
|
||||
return commandkit.Acquired[[]string]{Value: profiles, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[[]string]{Value: profiles, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
ListBackends: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]string], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
backends, err := engine.ListBackends(ctx, request.Filter)
|
||||
return commandkit.Acquired[[]string]{Value: backends, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[[]string]{Value: backends, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
ListNamespaces: func(ctx context.Context, request NamespaceRequest, input I, state S) (commandkit.Acquired[[]string], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
namespaces, err := engine.ListNamespaces(ctx, request.Kinds...)
|
||||
return commandkit.Acquired[[]string]{Value: namespaces, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[[]string]{Value: namespaces, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
ListCollections: func(ctx context.Context, selection ckconfig.Selection, input I, state S) (commandkit.Acquired[[]promptkit.CollectionInfo], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]promptkit.CollectionInfo]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[[]promptkit.CollectionInfo]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
collections, err := engine.ListCollections(ctx)
|
||||
return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: collections, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: collections, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
InspectPrompt: func(ctx context.Context, request PromptRequest, input I, state S) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
inspection, err := engine.InspectPrompt(ctx, request.ID)
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Value: inspection, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
InspectProfile: func(ctx context.Context, request ProfileRequest, input I, state S) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
inspection, err := engine.InspectProfile(ctx, request.ID)
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
InspectRequirements: func(ctx context.Context, request RequirementsRequest, input I, state S) (commandkit.Acquired[*promptkit.RequirementInspection], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
engine, _, messages, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: diagnostics}, err
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
}
|
||||
inspection, err := engine.InspectRequirements(ctx, request.Inspection)
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: inspection, Diagnostics: diagnostics}, err
|
||||
messages = messages.withOperationError(err)
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -362,41 +403,45 @@ func loadPromptKitEngine[I, S any](
|
||||
state S,
|
||||
side string,
|
||||
load engineLoader[I, S],
|
||||
) (*promptkit.Engine, promptkit.ConfigurationInspection, []commandkit.Diagnostic, error) {
|
||||
) (*promptkit.Engine, promptkit.ConfigurationInspection, promptKitAcquisition, error) {
|
||||
engine, err := load(ctx, selection, input, state)
|
||||
if err != nil {
|
||||
var loadErr *configkit.LoadError
|
||||
if errors.As(err, &loadErr) {
|
||||
return nil, promptkit.ConfigurationInspection{}, promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics()), err
|
||||
messages := promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics())
|
||||
return nil, promptkit.ConfigurationInspection{}, messages, err
|
||||
}
|
||||
return nil, promptkit.ConfigurationInspection{}, nil, err
|
||||
return nil, promptkit.ConfigurationInspection{}, promptKitAcquisition{}, err
|
||||
}
|
||||
if engine == nil {
|
||||
return nil, promptkit.ConfigurationInspection{}, nil, errors.New("PromptKit configuration loader returned a nil engine")
|
||||
return nil, promptkit.ConfigurationInspection{}, promptKitAcquisition{}, errors.New("PromptKit configuration loader returned a nil engine")
|
||||
}
|
||||
inspection, err := engine.InspectConfiguration()
|
||||
messages := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics())
|
||||
messages = messages.withOperationError(err)
|
||||
if err != nil {
|
||||
return nil, promptkit.ConfigurationInspection{}, nil, err
|
||||
return nil, inspection, messages, err
|
||||
}
|
||||
diagnostics := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics())
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, promptkit.ConfigurationInspection{}, diagnostics, err
|
||||
return nil, promptkit.ConfigurationInspection{}, messages, err
|
||||
}
|
||||
}
|
||||
return engine, inspection, diagnostics, nil
|
||||
return engine, inspection, messages, nil
|
||||
}
|
||||
|
||||
func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||
func appendPromptKitAcquisition(left, right promptKitAcquisition) promptKitAcquisition {
|
||||
left.diagnostics = append(left.diagnostics, right.diagnostics...)
|
||||
left.explanation.Details = append(left.explanation.Details, right.explanation.Details...)
|
||||
if left.explanation.Message == "" {
|
||||
left.explanation.Message = right.explanation.Message
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) promptKitAcquisition {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
severity := commandkit.SeverityWarning
|
||||
for _, record := range records {
|
||||
if record.Severity == configkit.SeverityError {
|
||||
severity = commandkit.SeverityError
|
||||
break
|
||||
}
|
||||
return promptKitAcquisition{}
|
||||
}
|
||||
// Snapshot provider-owned slices before deferring their report to invocation.
|
||||
sources = append([]configkit.SourceInfo(nil), sources...)
|
||||
@@ -408,9 +453,32 @@ func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, re
|
||||
}
|
||||
records[index].Related = append([]configkit.Location(nil), records[index].Related...)
|
||||
}
|
||||
return []commandkit.Diagnostic{{
|
||||
Severity: severity,
|
||||
var warnings, failures []configkit.Diagnostic
|
||||
for _, record := range records {
|
||||
if record.Severity == configkit.SeverityError {
|
||||
failures = append(failures, record)
|
||||
} else {
|
||||
warnings = append(warnings, record)
|
||||
}
|
||||
}
|
||||
messages := promptKitAcquisition{}
|
||||
if len(warnings) > 0 {
|
||||
messages.diagnostics = []commandkit.Diagnostic{{
|
||||
Severity: commandkit.SeverityWarning,
|
||||
Write: func(writer io.Writer) error {
|
||||
return writePromptKitDiagnostics(writer, scope, side, sources, warnings)
|
||||
},
|
||||
}}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
messages.explanation = commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
||||
return writePromptKitDiagnostics(writer, scope, side, sources, failures)
|
||||
}}}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func writePromptKitDiagnostics(writer io.Writer, scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) error {
|
||||
heading := "Configuration scope " + strconv.Quote(scope)
|
||||
if side != "" {
|
||||
heading += " (" + side + ")"
|
||||
@@ -429,6 +497,4 @@ func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, re
|
||||
}
|
||||
}
|
||||
return configkit.WriteDiagnostics(writer, records)
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/ckconfig"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
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: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
@@ -249,7 +249,7 @@ func TestWithSmokeTestingRetainsLoaderDiagnosticsAndErrorIdentity(t *testing.T)
|
||||
}, nil)
|
||||
acquired, err := suite.SmokeTest(context.Background(), SmokeRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || acquired.Value != nil || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || acquired.Value != nil || len(acquired.Explanation.Details) == 0 {
|
||||
t.Fatalf("smoke load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
}
|
||||
@@ -394,7 +394,7 @@ func TestConfigScopeCompareStopsAfterLeftLoadFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigScopeCompareRetainsRightLoadDiagnosticsAndErrorIdentity(t *testing.T) {
|
||||
func TestConfigScopeCompareRetainsRightLoadExplanationAndErrorIdentity(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid right prompt config", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid right configuration unexpectedly loaded")
|
||||
@@ -415,12 +415,12 @@ func TestConfigScopeCompareRetainsRightLoadDiagnosticsAndErrorIdentity(t *testin
|
||||
|
||||
acquired, err := scope.Compare(context.Background(), request, struct{}{})
|
||||
var retained *configkit.LoadError
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(calls) != 2 || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(calls) != 2 || len(acquired.Explanation.Details) == 0 {
|
||||
t.Fatalf("right failure = (%#v, %v), calls=%#v", acquired, err, calls)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if writeErr := acquired.Diagnostics[0].Write(&output); writeErr != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (right):`) {
|
||||
t.Fatalf("right diagnostic = %q, %v", output.String(), writeErr)
|
||||
if writeErr := acquired.Explanation.Details[0](&output); writeErr != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (right):`) {
|
||||
t.Fatalf("right explanation = %q, %v", output.String(), writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,7 +534,7 @@ func TestFromEngineInspectsRequirementsWithNativeRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineRequirementsRetainLoadDiagnosticsAndSelectionErrors(t *testing.T) {
|
||||
func TestFromEngineRequirementsRetainLoadExplanationAndSelectionErrors(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid requirements configuration", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
@@ -544,11 +544,8 @@ func TestFromEngineRequirementsRetainLoadDiagnosticsAndSelectionErrors(t *testin
|
||||
})
|
||||
acquired, err := loadFailure.InspectRequirements(context.Background(), RequirementsRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 || acquired.Value != nil {
|
||||
t.Fatalf("requirements load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err)
|
||||
}
|
||||
if acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics)
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 || acquired.Value != nil {
|
||||
t.Fatalf("requirements load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err)
|
||||
}
|
||||
|
||||
nilEngine := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
@@ -651,7 +648,7 @@ func TestFromEngineListsNativeCatalogsWithInvocationValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineCatalogsRetainLoadDiagnosticsAndErrorIdentity(t *testing.T) {
|
||||
func TestFromEngineCatalogsRetainLoadExplanationAndErrorIdentity(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid catalog configuration", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
@@ -666,42 +663,42 @@ func TestFromEngineCatalogsRetainLoadDiagnosticsAndErrorIdentity(t *testing.T) {
|
||||
{name: "prompts", call: func() (int, error) {
|
||||
acquired, err := suite.ListPrompts(context.Background(), CatalogRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Errorf("prompt load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
|
||||
}},
|
||||
{name: "profiles", call: func() (int, error) {
|
||||
acquired, err := suite.ListProfiles(context.Background(), CatalogRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Errorf("profile load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
|
||||
}},
|
||||
{name: "backends", call: func() (int, error) {
|
||||
acquired, err := suite.ListBackends(context.Background(), CatalogRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Errorf("backend load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
|
||||
}},
|
||||
{name: "namespaces", call: func() (int, error) {
|
||||
acquired, err := suite.ListNamespaces(context.Background(), NamespaceRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Errorf("namespace load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
|
||||
}},
|
||||
{name: "collections", call: func() (int, error) {
|
||||
acquired, err := suite.ListCollections(context.Background(), ckconfig.Selection{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Errorf("collection load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
return len(acquired.Diagnostics) + len(acquired.Explanation.Details), err
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -782,7 +779,7 @@ func TestAdapterAcquisitionPreservesLoadAndContextErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineRetainsConfigKitLoadDiagnostics(t *testing.T) {
|
||||
func TestFromEngineRetainsConfigKitLoadExplanation(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid PromptKit config", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
@@ -793,11 +790,8 @@ func TestFromEngineRetainsConfigKitLoadDiagnostics(t *testing.T) {
|
||||
|
||||
acquired, err := suite.InspectProfile(context.Background(), ProfileRequest{ID: "default"}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err)
|
||||
}
|
||||
if acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics)
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Explanation.Details) == 0 {
|
||||
t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and terminal context", acquired, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,25 +817,55 @@ func TestPromptKitDiagnosticsRemainDeferredAndPropagateWriterErrors(t *testing.T
|
||||
Severity: configkit.SeverityWarning,
|
||||
Message: "safe warning",
|
||||
}}
|
||||
diagnostics := promptKitDiagnostics("promptkit", "", sources, records)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
messages := promptKitDiagnostics("promptkit", "", sources, records)
|
||||
if len(messages.diagnostics) != 1 || messages.diagnostics[0].Severity != commandkit.SeverityWarning || len(messages.explanation.Details) != 0 {
|
||||
t.Fatalf("diagnostic messages = %#v", messages)
|
||||
}
|
||||
if err := diagnostics[0].Write(failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) {
|
||||
if err := messages.diagnostics[0].Write(failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) {
|
||||
t.Fatalf("diagnostic write error = %v", err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit":`) || !strings.Contains(output.String(), "safe warning") || !strings.Contains(output.String(), `"source"`) {
|
||||
if err := messages.diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit":`) || !strings.Contains(output.String(), "safe warning") || !strings.Contains(output.String(), `"source"`) {
|
||||
t.Fatalf("diagnostic text = %q, %v", output.String(), err)
|
||||
}
|
||||
|
||||
sideDiagnostics := promptKitDiagnostics("promptkit", "left", sources, records)
|
||||
output.Reset()
|
||||
if err := sideDiagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (left):`) {
|
||||
if err := sideDiagnostics.diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (left):`) {
|
||||
t.Fatalf("comparison-side diagnostic text = %q, %v", output.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptKitDiagnosticsSeparateIndependentWarningsAndTerminalRecords(t *testing.T) {
|
||||
sources := []configkit.SourceInfo{{ID: "source", Label: "safe label", Path: "config.yml"}}
|
||||
primary := configkit.Location{SourceID: "source", Line: 3, Column: 2}
|
||||
related := configkit.Location{SourceID: "base.yml", Line: 7, Column: 4}
|
||||
messages := promptKitDiagnostics("promptkit", "right", sources, []configkit.Diagnostic{
|
||||
{Severity: configkit.SeverityWarning, Message: "independent warning", Primary: &primary, Related: []configkit.Location{related}},
|
||||
{Severity: configkit.SeverityError, Message: "terminal configuration error", Primary: &related, Related: []configkit.Location{primary}},
|
||||
})
|
||||
if len(messages.diagnostics) != 1 || len(messages.explanation.Details) != 1 {
|
||||
t.Fatalf("mixed acquisition messages = %#v", messages)
|
||||
}
|
||||
writeErr := errors.New("terminal explanation writer failed")
|
||||
if err := messages.explanation.Details[0](failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) {
|
||||
t.Fatalf("terminal explanation write error = %v", err)
|
||||
}
|
||||
var warning, terminal bytes.Buffer
|
||||
if err := messages.diagnostics[0].Write(&warning); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := messages.explanation.Details[0](&terminal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := warning.String(); !strings.Contains(got, "independent warning") || !strings.Contains(got, `Configuration scope "promptkit" (right):`) || !strings.Contains(got, `"source": label="safe label", path="config.yml"`) || !strings.Contains(got, "source:3:2") || !strings.Contains(got, "related: base.yml:7:4") || strings.Contains(got, "terminal configuration error") {
|
||||
t.Fatalf("independent warning report = %q", got)
|
||||
}
|
||||
if got := terminal.String(); !strings.Contains(got, "terminal configuration error") || !strings.Contains(got, `Configuration scope "promptkit" (right):`) || !strings.Contains(got, `"source": label="safe label", path="config.yml"`) || !strings.Contains(got, "base.yml:7:4") || !strings.Contains(got, "related: source:3:2") || strings.Contains(got, "independent warning") {
|
||||
t.Fatalf("terminal explanation report = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type failingDiagnosticWriter struct{ err error }
|
||||
|
||||
func (writer failingDiagnosticWriter) Write([]byte) (int, error) { return 0, writer.err }
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/ckconfig"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -134,11 +134,11 @@ func newCatalogListCommand[I, S any](suite Suite[I, S], name string) commandkit.
|
||||
return &(*input).captured
|
||||
})
|
||||
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 }),
|
||||
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 }),
|
||||
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 }),
|
||||
)
|
||||
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 {
|
||||
switch name {
|
||||
case "prompts":
|
||||
return runCatalogList(invocation, suite, input, suite.ListPrompts, encodePromptCatalog)
|
||||
return runCatalogList(invocation, suite, input, name, suite.ListPrompts, encodePromptCatalog)
|
||||
case "profiles":
|
||||
return runCatalogList(invocation, suite, input, suite.ListProfiles, encodeProfileCatalog)
|
||||
return runCatalogList(invocation, suite, input, name, suite.ListProfiles, encodeProfileCatalog)
|
||||
case "backends":
|
||||
return runCatalogList(invocation, suite, input, suite.ListBackends, encodeProfileCatalog)
|
||||
return runCatalogList(invocation, suite, input, name, suite.ListBackends, encodeProfileCatalog)
|
||||
case "namespaces":
|
||||
list := func(ctx context.Context, request CatalogRequest, value I, state S) (commandkit.Acquired[[]string], error) {
|
||||
return suite.ListNamespaces(ctx, NamespaceRequest{Selection: request.Selection, Kinds: input.kinds}, value, state)
|
||||
}
|
||||
return runCatalogList(invocation, suite, input, list, encodeProfileCatalog)
|
||||
return runCatalogList(invocation, suite, input, name, list, encodeProfileCatalog)
|
||||
case "collections":
|
||||
list := func(ctx context.Context, request CatalogRequest, value I, state S) (commandkit.Acquired[[]promptkit.CollectionInfo], error) {
|
||||
return suite.ListCollections(ctx, request.Selection, value, state)
|
||||
}
|
||||
return runCatalogList(invocation, suite, input, list, encodeCollectionCatalog)
|
||||
return runCatalogList(invocation, suite, input, name, list, encodeCollectionCatalog)
|
||||
default:
|
||||
return definitionFailure(fmt.Errorf("unknown PromptKit catalog %q", name), nil)
|
||||
return definitionFailure("Unable to list the selected PromptKit catalog.", commandkit.Explanation{}, fmt.Errorf("unknown PromptKit catalog %q", name))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -256,13 +256,13 @@ func newDefinitionCommand[I, S any](suite Suite[I, S], name string, prompt bool)
|
||||
return &(*input).captured
|
||||
})
|
||||
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 }),
|
||||
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]) {
|
||||
(*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 }),
|
||||
)
|
||||
spec := commandkit.CommandSpec[*definitionInput[I]]{
|
||||
@@ -302,11 +302,11 @@ func newRequirementsCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
||||
return &(*input).captured
|
||||
})
|
||||
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]) {
|
||||
(*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]) {
|
||||
(*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]) {
|
||||
(*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 }),
|
||||
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "var", Usage: "Caller variable assignment KEY=VALUE."}, []variableAssignment(nil), parseVariableAssignment,
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[[]variableAssignment]) {
|
||||
@@ -385,12 +385,13 @@ func runCatalogList[T any, I, S any](
|
||||
invocation commandkit.Invocation,
|
||||
suite Suite[I, S],
|
||||
input *catalogInput[I],
|
||||
catalog string,
|
||||
list func(context.Context, CatalogRequest, I, S) (commandkit.Acquired[T], error),
|
||||
encode func(T) ([]byte, error),
|
||||
) commandkit.Outcome {
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit catalog listing was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
}
|
||||
var state S
|
||||
@@ -399,30 +400,29 @@ func runCatalogList[T any, I, S any](
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit catalog listing was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
}
|
||||
|
||||
acquired, err := list(invocation.Context, CatalogRequest{Selection: input.selection, Filter: input.filter}, input.selected, state)
|
||||
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Unable to list PromptKit %s.", catalog), acquired.Explanation, err)
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure("PromptKit catalog listing was canceled.", acquired.Explanation, err)
|
||||
}
|
||||
}
|
||||
output, err := encode(acquired.Value)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
return writePromptReport(invocation, output, acquired.Diagnostics)
|
||||
return renderPreparedPromptReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||
return encode(acquired.Value)
|
||||
})
|
||||
}
|
||||
|
||||
func runDefinitionInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *definitionInput[I], prompt bool) commandkit.Outcome {
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit inspection was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
}
|
||||
var state S
|
||||
@@ -431,41 +431,43 @@ func runDefinitionInspection[I, S any](invocation commandkit.Invocation, suite S
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit inspection was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
}
|
||||
|
||||
if prompt {
|
||||
request := PromptRequest{Selection: input.selection, ID: input.id}
|
||||
acquired, err := suite.InspectPrompt(invocation.Context, request, input.selected, state)
|
||||
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Unable to inspect PromptKit prompt %q.", input.id), acquired.Explanation, err)
|
||||
}
|
||||
if acquired.Value == nil {
|
||||
return definitionFailure(errors.New("PromptKit prompt inspection returned nil"), acquired.Diagnostics)
|
||||
return definitionFailure("PromptKit prompt inspection returned no result.", acquired.Explanation, nil)
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Inspection of PromptKit prompt %q was canceled.", input.id), acquired.Explanation, err)
|
||||
}
|
||||
}
|
||||
return renderPromptReport(invocation, promptReportFromInspection(acquired.Value), input.format, acquired.Diagnostics)
|
||||
return renderPromptReport(invocation, acquired.Value, input.format, acquired.Explanation)
|
||||
}
|
||||
|
||||
request := ProfileRequest{Selection: input.selection, ID: input.id}
|
||||
acquired, err := suite.InspectProfile(invocation.Context, request, input.selected, state)
|
||||
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Unable to inspect PromptKit profile %q.", input.id), acquired.Explanation, err)
|
||||
}
|
||||
if acquired.Value == nil {
|
||||
return definitionFailure(errors.New("PromptKit profile inspection returned nil"), acquired.Diagnostics)
|
||||
return definitionFailure("PromptKit profile inspection returned no result.", acquired.Explanation, nil)
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Inspection of PromptKit profile %q was canceled.", input.id), acquired.Explanation, err)
|
||||
}
|
||||
}
|
||||
return renderProfileReport(invocation, profileReportFromInspection(acquired.Value), input.format, acquired.Diagnostics)
|
||||
return renderProfileReport(invocation, acquired.Value, input.format, acquired.Explanation)
|
||||
}
|
||||
|
||||
type promptInputReport struct {
|
||||
@@ -624,7 +626,7 @@ func profileReportFromInspection(inspection *promptkit.ProfileInspection) profil
|
||||
func runRequirementsInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *requirementsInput[I]) commandkit.Outcome {
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit requirements inspection was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
}
|
||||
var state S
|
||||
@@ -633,56 +635,69 @@ func runRequirementsInspection[I, S any](invocation commandkit.Invocation, suite
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
return definitionFailure("PromptKit requirements inspection was canceled.", commandkit.Explanation{}, err)
|
||||
}
|
||||
}
|
||||
|
||||
request := RequirementsRequest{Selection: input.selection, Inspection: input.inspection}
|
||||
acquired, err := suite.InspectRequirements(invocation.Context, request, input.selected, state)
|
||||
queuePromptDiagnostics(invocation.Reporter(), acquired.Diagnostics)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Unable to inspect requirements for PromptKit prompt %q.", input.inspection.PromptID), acquired.Explanation, err)
|
||||
}
|
||||
if acquired.Value == nil {
|
||||
return definitionFailure(errors.New("PromptKit requirements inspection returned nil"), acquired.Diagnostics)
|
||||
return definitionFailure("PromptKit requirements inspection returned no result.", acquired.Explanation, nil)
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
return definitionFailure(fmt.Sprintf("Requirements inspection for PromptKit prompt %q was canceled.", input.inspection.PromptID), acquired.Explanation, err)
|
||||
}
|
||||
}
|
||||
output, err := encodePromptYAMLBytes(requirementsReportFromInspection(acquired.Value))
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
return writePromptReport(invocation, output, acquired.Diagnostics)
|
||||
return renderPreparedPromptReport(invocation, acquired.Explanation, func() ([]byte, error) {
|
||||
return encodePromptYAMLBytes(requirementsReportFromInspection(acquired.Value))
|
||||
})
|
||||
}
|
||||
|
||||
func renderPromptReport(invocation commandkit.Invocation, report promptInspectionReport, format string, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
var output bytes.Buffer
|
||||
func renderPromptReport(invocation commandkit.Invocation, inspection *promptkit.PromptInspection, format string, explanation commandkit.Explanation) commandkit.Outcome {
|
||||
return renderPreparedPromptReport(invocation, explanation, func() ([]byte, error) {
|
||||
report := promptReportFromInspection(inspection)
|
||||
if format == "yaml" {
|
||||
if err := encodePromptYAML(&output, report); err != nil {
|
||||
return definitionFailure(err, diagnostics)
|
||||
return encodePromptYAMLBytes(report)
|
||||
}
|
||||
} else {
|
||||
output.WriteString(formatPromptText(report))
|
||||
}
|
||||
return writePromptReport(invocation, output.Bytes(), diagnostics)
|
||||
return []byte(formatPromptText(report)), nil
|
||||
})
|
||||
}
|
||||
|
||||
func renderProfileReport(invocation commandkit.Invocation, report profileInspectionReport, format string, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
var output bytes.Buffer
|
||||
func renderProfileReport(invocation commandkit.Invocation, inspection *promptkit.ProfileInspection, format string, explanation commandkit.Explanation) commandkit.Outcome {
|
||||
return renderPreparedPromptReport(invocation, explanation, func() ([]byte, error) {
|
||||
report := profileReportFromInspection(inspection)
|
||||
if format == "yaml" {
|
||||
if err := encodePromptYAML(&output, report); err != nil {
|
||||
return definitionFailure(err, diagnostics)
|
||||
return encodePromptYAMLBytes(report)
|
||||
}
|
||||
} else {
|
||||
text, err := formatProfileText(report)
|
||||
return []byte(text), err
|
||||
})
|
||||
}
|
||||
|
||||
// These reports were already prepared before delivery. Keep that boundary
|
||||
// inside managed rendering, with cancellation owned by the command outcome.
|
||||
func renderPreparedPromptReport(invocation commandkit.Invocation, explanation commandkit.Explanation, prepare func() ([]byte, error)) commandkit.Outcome {
|
||||
var canceled error
|
||||
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
|
||||
output, err := prepare()
|
||||
if err != nil {
|
||||
return definitionFailure(err, diagnostics)
|
||||
return err
|
||||
}
|
||||
output.WriteString(text)
|
||||
if canceled = promptKitContextError(invocation.Context); canceled != nil {
|
||||
return nil
|
||||
}
|
||||
return writePromptReport(invocation, output.Bytes(), diagnostics)
|
||||
_, err = writer.Write(output)
|
||||
return err
|
||||
})
|
||||
if canceled != nil {
|
||||
return definitionFailure("PromptKit report preparation was canceled.", explanation, canceled)
|
||||
}
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
func formatPromptText(report promptInspectionReport) string {
|
||||
@@ -771,30 +786,33 @@ func encodePromptYAMLBytes(value any) ([]byte, error) {
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
func writePromptReport(invocation commandkit.Invocation, output []byte, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, diagnostics)
|
||||
func queuePromptDiagnostics(reporter *commandkit.Reporter, diagnostics []commandkit.Diagnostic) {
|
||||
deferred := reporter.AtEnd()
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Write == nil {
|
||||
continue
|
||||
}
|
||||
switch diagnostic.Severity {
|
||||
case commandkit.SeverityInfo:
|
||||
_ = deferred.Info("", diagnostic.Write)
|
||||
case commandkit.SeverityDebug:
|
||||
_ = deferred.Debug("", diagnostic.Write)
|
||||
case commandkit.SeverityProgress:
|
||||
_ = reporter.Progress("", diagnostic.Write)
|
||||
default:
|
||||
_ = deferred.Warn("", diagnostic.Write)
|
||||
}
|
||||
}
|
||||
written, err := invocation.Streams.Out.Write(output)
|
||||
if err == nil && written != len(output) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil {
|
||||
outcome := definitionFailure(err, diagnostics)
|
||||
outcome.ReportIncomplete = true
|
||||
outcome.DeliveryError = err
|
||||
return outcome
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
func definitionFailure(cause error, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(cause)
|
||||
outcome.Diagnostics = diagnostics
|
||||
func definitionFailure(message string, explanation commandkit.Explanation, cause error) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(message)
|
||||
if explanation.Message != "" || len(explanation.Details) > 0 {
|
||||
outcome = outcome.WithExplanation(explanation.Message, explanation.Details...)
|
||||
}
|
||||
if cause != nil {
|
||||
outcome = outcome.WithCause(cause)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,15 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/ckconfig"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func newPromptTestApp(t *testing.T, commands ...commandkit.Command) *commandkit.App {
|
||||
func newPromptTestApp(t *testing.T, declarations ...commandkit.Declaration) *commandkit.App {
|
||||
t.Helper()
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, commands...)
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, declarations...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -255,7 +255,7 @@ func TestCatalogCommandTreesAreIndependentAndOmitShow(t *testing.T) {
|
||||
app := newPromptTestApp(t, test.cmd)
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"help", "promptkit", "list"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "list") || !strings.Contains(output.String(), test.want) || strings.Contains(output.String(), test.omit) || strings.Contains(output.String(), "show") {
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "list") || !strings.Contains(output.String(), test.want) || strings.Contains(output.String(), "\n "+test.omit+" ") || strings.Contains(output.String(), "\n show ") {
|
||||
t.Fatalf("catalog-only help = (%#v, %q)", outcome, output.String())
|
||||
}
|
||||
})
|
||||
@@ -701,41 +701,39 @@ func TestCatalogRejectsOperandsAndUnavailableSelectors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogFailuresRetainDiagnosticsAndDeliveryState(t *testing.T) {
|
||||
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "catalog warning\n")
|
||||
return err
|
||||
}}
|
||||
hasCatalogWarning := func(diagnostics []commandkit.Diagnostic) bool {
|
||||
for _, got := range diagnostics {
|
||||
if got.Severity != diagnostic.Severity || got.Write == nil {
|
||||
continue
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if got.Write(&output) == nil && output.String() == "catalog warning\n" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func TestCatalogFailureReportsWarningAndNativeExplanationSeparately(t *testing.T) {
|
||||
diagnostic := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "catalog warning")
|
||||
providerErr := errors.New("catalog provider failed")
|
||||
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ListPrompts: func(context.Context, CatalogRequest, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, providerErr
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{
|
||||
Diagnostics: []commandkit.Diagnostic{diagnostic},
|
||||
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "PromptKit lookup context\n")
|
||||
return err
|
||||
}}},
|
||||
}, providerErr
|
||||
}}))
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "list", "prompts"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, providerErr) || !hasCatalogWarning(outcome.Diagnostics) {
|
||||
var output, diagnosticOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "list", "prompts", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, providerErr) {
|
||||
t.Fatalf("provider failure outcome = %#v", outcome)
|
||||
}
|
||||
warningAt := strings.Index(diagnosticOutput.String(), "catalog warning")
|
||||
explanationAt := strings.Index(diagnosticOutput.String(), "PromptKit lookup context")
|
||||
if warningAt < 0 || explanationAt < 0 || warningAt >= explanationAt || strings.Count(diagnosticOutput.String(), "catalog warning") != 1 {
|
||||
t.Fatalf("warning/native terminal context were not independently ordered once: %q", diagnosticOutput.String())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
canceled := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{ListPrompts: func(context.Context, CatalogRequest, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
|
||||
cancel()
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "briefing", Version: testStringPointer("1")}}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
||||
}}))
|
||||
var output bytes.Buffer
|
||||
outcome = canceled.Execute(ctx, []string{"promptkit", "list", "prompts"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !hasCatalogWarning(outcome.Diagnostics) || output.Len() != 0 {
|
||||
t.Fatalf("post-provider cancellation outcome = %#v output=%q", outcome, output.String())
|
||||
output.Reset()
|
||||
diagnosticOutput.Reset()
|
||||
outcome = canceled.Execute(ctx, []string{"promptkit", "list", "prompts", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || !strings.Contains(diagnosticOutput.String(), "catalog warning") || output.Len() != 0 {
|
||||
t.Fatalf("post-provider cancellation outcome = %#v output=%q err=%q", outcome, output.String(), diagnosticOutput.String())
|
||||
}
|
||||
|
||||
writeErr := errors.New("catalog report writer failed")
|
||||
@@ -752,13 +750,96 @@ func TestCatalogFailuresRetainDiagnosticsAndDeliveryState(t *testing.T) {
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "briefing", Version: testStringPointer("1")}}, Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
||||
}}))
|
||||
outcome := writerApp.Execute(context.Background(), []string{"promptkit", "list", "prompts"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, test.want) || !errors.Is(outcome.Cause, test.want) || !outcome.ReportIncomplete || !hasCatalogWarning(outcome.Diagnostics) {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("catalog delivery outcome = %#v", outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogAndInspectionRespectSuccessfulReportingModes(t *testing.T) {
|
||||
for _, mode := range []string{"ordinary", "quiet", "verbose", "debug"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
calls := 0
|
||||
suite := Suite[struct{}, struct{}]{
|
||||
ListPrompts: func(context.Context, CatalogRequest, struct{}, struct{}) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
|
||||
calls++
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: []promptkit.PromptIdentity{{ID: "briefing"}}}, nil
|
||||
},
|
||||
InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
calls++
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{PromptID: "briefing"}}, nil
|
||||
},
|
||||
}
|
||||
app := newPromptTestApp(t, Command(suite))
|
||||
args := []string{"promptkit", "list", "prompts"}
|
||||
if mode != "ordinary" {
|
||||
args = append(args, "--"+mode)
|
||||
}
|
||||
var output, diagnosticOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || calls != 1 {
|
||||
t.Fatalf("%s listing = %#v after %d calls", mode, outcome, calls)
|
||||
}
|
||||
if mode == "quiet" {
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("quiet listing wrote result output %q", output.String())
|
||||
}
|
||||
} else if !strings.Contains(output.String(), "briefing") {
|
||||
t.Fatalf("%s listing omitted result: %q", mode, output.String())
|
||||
}
|
||||
if mode == "debug" && !strings.Contains(diagnosticOutput.String(), "DEBUG:") {
|
||||
t.Fatalf("debug listing omitted core debug events: %q", diagnosticOutput.String())
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
diagnosticOutput.Reset()
|
||||
args = []string{"promptkit", "show", "prompt", "briefing"}
|
||||
if mode != "ordinary" {
|
||||
args = append(args, "--"+mode)
|
||||
}
|
||||
outcome = app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || calls != 2 {
|
||||
t.Fatalf("%s inspection = %#v after %d callbacks", mode, outcome, calls)
|
||||
}
|
||||
if mode == "quiet" {
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("quiet inspection wrote result output %q", output.String())
|
||||
}
|
||||
} else if !strings.Contains(output.String(), "Prompt ID: \"briefing\"") {
|
||||
t.Fatalf("%s inspection omitted result: %q", mode, output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuietInspectionStillFindsReportEncodingFailure(t *testing.T) {
|
||||
called := false
|
||||
app := newPromptTestApp(t, Command(Suite[struct{}, struct{}]{InspectProfile: func(context.Context, ProfileRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
|
||||
called = true
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: &promptkit.ProfileInspection{
|
||||
ProfileID: "broken",
|
||||
EffectiveModelParams: promptkit.ExecutionTarget{ExtraParams: map[string]any{"unsupported": func() {}}},
|
||||
}}, nil
|
||||
}}))
|
||||
result := &countingWriter{}
|
||||
var diagnosticOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "show", "profile", "broken", "--quiet"}, commandkit.Streams{Out: result, Err: &diagnosticOutput})
|
||||
if !called || result.calls != 0 {
|
||||
t.Fatalf("quiet rendering called=%t destination writes=%d", called, result.calls)
|
||||
}
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Presentation.Output.RenderError == nil {
|
||||
t.Fatalf("quiet serialization failure outcome = %#v diagnostics=%q", outcome, diagnosticOutput.String())
|
||||
}
|
||||
}
|
||||
|
||||
type countingWriter struct{ calls int }
|
||||
|
||||
func (writer *countingWriter) Write(p []byte) (int, error) {
|
||||
writer.calls++
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func TestCommandUsesInstalledNamespaceCollisionChecks(t *testing.T) {
|
||||
command := Command(Suite[struct{}, struct{}]{InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{}, nil
|
||||
@@ -1008,7 +1089,7 @@ func TestNativeLookupErrorsAndBlankIDsReachCallbacks(t *testing.T) {
|
||||
}
|
||||
output.Reset()
|
||||
diagnosticOutput.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"promptkit", "show", "prompt", ""}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
outcome = app.Execute(context.Background(), []string{"promptkit", "show", "prompt", "", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, nativeErr) || calls != 1 || output.Len() != 0 || !strings.Contains(diagnosticOutput.String(), "lookup context") {
|
||||
t.Fatalf("native lookup failure = (%#v, calls=%d, out=%q, err=%q)", outcome, calls, output.String(), diagnosticOutput.String())
|
||||
}
|
||||
@@ -1055,7 +1136,7 @@ func TestDefinitionFormatPreflightAndReportDeliveryFailure(t *testing.T) {
|
||||
|
||||
writeErr := errors.New("report writer failed")
|
||||
outcome = app.Execute(context.Background(), []string{"promptkit", "show", "profile", "default"}, commandkit.Streams{Out: partialReportWriter{err: writeErr}, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, writeErr) || outcome.DeliveryError != writeErr || !outcome.ReportIncomplete || calls != 1 {
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || calls != 1 {
|
||||
t.Fatalf("report delivery outcome = %#v calls=%d", outcome, calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"testing"
|
||||
"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"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/ckconfig"
|
||||
"go.maximumdirect.net/ckprompt"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
"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"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"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"
|
||||
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] }
|
||||
@@ -66,6 +67,80 @@ func TestPublicDefinitionCommandsCaptureSelectorHandles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptKitAliasesComposeWithInstalledOperations(t *testing.T) {
|
||||
type input struct{ Campaign commandkit.Supplied[string] }
|
||||
selector := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign"}, (*string)(nil),
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(target *input, value commandkit.Supplied[string]) { target.Campaign = value })
|
||||
var collectionInput input
|
||||
var inspectionInput input
|
||||
var inspectedID string
|
||||
var smokeInput input
|
||||
var smokeKind promptkit.SmokeTestKind
|
||||
callbacks := 0
|
||||
suite := ckprompt.Suite[input, struct{}]{
|
||||
Selectors: []commandkit.Flag[input]{selector},
|
||||
ListCollections: func(_ context.Context, _ ckconfig.Selection, value input, _ struct{}) (commandkit.Acquired[[]promptkit.CollectionInfo], error) {
|
||||
callbacks++
|
||||
collectionInput = value
|
||||
return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: []promptkit.CollectionInfo{{Namespace: "spring"}}}, nil
|
||||
},
|
||||
InspectPrompt: func(_ context.Context, request ckprompt.PromptRequest, value input, _ struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
callbacks++
|
||||
inspectionInput, inspectedID = value, request.ID
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{PromptID: request.ID}}, nil
|
||||
},
|
||||
SmokeTest: func(_ context.Context, request ckprompt.SmokeRequest, value input, _ struct{}) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
|
||||
callbacks++
|
||||
smokeInput, smokeKind = value, request.Request.Kind
|
||||
return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind, Supported: true, Completed: true}}, nil
|
||||
},
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"},
|
||||
ckprompt.Command(suite),
|
||||
commandkit.Alias("collect", "promptkit list collections"),
|
||||
commandkit.Alias("inspect", "promptkit show prompt"),
|
||||
commandkit.Alias("smoke", "promptkit test"),
|
||||
commandkit.FlagAliasFor("promptkit list collections", "--camp", "--campaign"),
|
||||
commandkit.FlagAliasFor("promptkit show prompt", "--camp", "--campaign"),
|
||||
commandkit.FlagAlias("--hlep", "--help"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"collect", "--camp=spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !collectionInput.Campaign.Set || collectionInput.Campaign.Value != "spring" || !strings.Contains(output.String(), "spring") {
|
||||
t.Fatalf("collection shortcut outcome/input/report = (%#v, %#v, %q)", outcome, collectionInput, output.String())
|
||||
}
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"inspect", "briefing", "--camp", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !inspectionInput.Campaign.Set || inspectionInput.Campaign.Value != "spring" || inspectedID != "briefing" {
|
||||
t.Fatalf("inspection shortcut outcome/input/id = (%#v, %#v, %q)", outcome, inspectionInput, inspectedID)
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"smoke", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--help") || callbacks != 2 {
|
||||
t.Fatalf("global help alias outcome/report/callbacks = (%#v, %q, %d)", outcome, output.String(), callbacks)
|
||||
}
|
||||
|
||||
if err := app.Register(commandkit.Alias("promptkit test text-inference", "promptkit test authentication")); err == nil {
|
||||
t.Fatal("accepted a command alias that maps one smoke operand to another")
|
||||
}
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"promptkit", "test", "authentication", "--campaign=spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || smokeKind != promptkit.SmokeTestAuthentication || !smokeInput.Campaign.Set || smokeInput.Campaign.Value != "spring" || !strings.Contains(output.String(), "kind: authentication") || !strings.Contains(output.String(), "completed: true") {
|
||||
t.Fatalf("canonical smoke outcome/request/report = (%#v, %#v, %q)", outcome, smokeKind, output.String())
|
||||
}
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"smoke", "text-inference", "--campaign=spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || smokeKind != promptkit.SmokeTestKind("text-inference") || callbacks != 4 || !strings.Contains(output.String(), "kind: text-inference") {
|
||||
t.Fatalf("smoke shortcut outcome/kind/report/callbacks = (%#v, %#v, %q, %d)", outcome, smokeKind, output.String(), callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicConfigurationAndDefinitionFamiliesRemainIndependent(t *testing.T) {
|
||||
inputs, err := promptkit.FilesystemInputs("composition fixture", fstest.MapFS{
|
||||
"config/prompts.yml": &fstest.MapFile{Data: []byte(`prompts:
|
||||
|
||||
@@ -1,5 +1,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,
|
||||
// and native smoke testing for CommandKit. Importing ckprompt installs neither
|
||||
// family; callers compose its declarations with core and ckconfig explicitly.
|
||||
// and native smoke testing for CommandKit. Its command factories return
|
||||
// canonical commandkit.Command declarations. Applications can install
|
||||
// commandkit.RoutingRule aliases alongside them through commandkit.New or
|
||||
// App.Register. Importing ckprompt installs neither family; callers compose its
|
||||
// declarations with core and ckconfig explicitly. Provider diagnostics remain
|
||||
// passive until the owning command submits them through the invocation
|
||||
// reporter, separately from any terminal explanation.
|
||||
package ckprompt
|
||||
|
||||
@@ -28,16 +28,29 @@ License and notices: [LGPL v3.0](../../LICENSE) |
|
||||
|
||||
## 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
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
PromptKit comparison and catalogs, definition and requirements inspection, and
|
||||
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 namespaces --campaign spring
|
||||
go run . promptkit list collections --campaign spring
|
||||
go run . list collections --campaign spring
|
||||
go run . pk list bundles --campaign spring
|
||||
go run . promptkit list prompts --namespace field-notes --campaign spring
|
||||
go run . promptkit show prompt briefing --campaign spring --format yaml
|
||||
go run . pk show prompt briefing --campaign spring --fmt yaml
|
||||
go run . promptkit show profile spring --campaign spring
|
||||
go run . promptkit requirements show briefing --campaign spring
|
||||
go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml
|
||||
@@ -24,10 +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
|
||||
go run . promptkit test text_inference --campaign spring --prompt briefing --profile spring
|
||||
go run . smoke text_inference --campaign spring --prompt briefing --profile spring
|
||||
go run . promptkit test all --campaign spring --prompt briefing --profile spring
|
||||
go run . pk --hlep
|
||||
go run . session restore session-42 --campaign spring
|
||||
```
|
||||
|
||||
`newApp` installs application-owned command and flag aliases alongside the
|
||||
complete `ckconfig` and `ckprompt` declarations. `list collections` and
|
||||
`promptkit list bundles` both reach the canonical collection command; `pk`
|
||||
shortens the PromptKit group. `--fmt` is scoped to named prompt inspection and
|
||||
`--hlep` is available globally. The `smoke` shortcut retains the canonical
|
||||
`promptkit test KIND` operand, including the special `all` aggregate.
|
||||
|
||||
The 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
|
||||
typed selector shared by the app and PromptKit configuration scopes. Their
|
||||
`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
|
||||
`requirements show` commands remain discovery reports: partial discovery can
|
||||
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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"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"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/ckconfig"
|
||||
"go.maximumdirect.net/ckprompt"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
)
|
||||
|
||||
type campaign struct {
|
||||
@@ -65,6 +66,8 @@ type session struct {
|
||||
Summary string
|
||||
}
|
||||
|
||||
var errSessionNotFound = errors.New("saved session was not found")
|
||||
|
||||
func campaignSelector() commandkit.Flag[*inspectionInput] {
|
||||
return commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Usage: "Select campaign inputs."}, (*string)(nil), parseString,
|
||||
func(input **inspectionInput, value commandkit.Supplied[string]) { (*input).Campaign = value })
|
||||
@@ -200,6 +203,14 @@ func newApp() (*commandkit.App, error) {
|
||||
}
|
||||
|
||||
func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
|
||||
return newAppWithEngineLoader(client, loadPromptEngine, nil)
|
||||
}
|
||||
|
||||
func newAppWithEngineLoader(
|
||||
client promptkit.LLMClient,
|
||||
engineLoader func(context.Context, ckconfig.Selection, *inspectionInput, *runCache) (*promptkit.Engine, error),
|
||||
smokeTest func(context.Context, ckprompt.SmokeRequest, *inspectionInput, *runCache) (commandkit.Acquired[*promptkit.SmokeTestReport], error),
|
||||
) (*commandkit.App, error) {
|
||||
if client == nil {
|
||||
client = &exampleLLMClient{}
|
||||
}
|
||||
@@ -210,7 +221,7 @@ func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
|
||||
stateFactory := func() *runCache { return &runCache{llmClient: client} }
|
||||
|
||||
appScope := ckconfig.FromConfigKit("app", "Application settings.", loadApplicationConfig)
|
||||
engineScope := ckprompt.ConfigScope(loadPromptEngine)
|
||||
engineScope := ckprompt.ConfigScope(engineLoader)
|
||||
configCommand := ckconfig.Command(ckconfig.Suite[*inspectionInput, *runCache]{
|
||||
NewInput: inputFactory,
|
||||
NewState: stateFactory,
|
||||
@@ -227,12 +238,15 @@ func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
|
||||
Scopes: []ckconfig.Scope[*inspectionInput, *runCache]{appScope, engineScope},
|
||||
})
|
||||
|
||||
definitions := ckprompt.FromEngine(loadPromptEngine)
|
||||
definitions := ckprompt.FromEngine(engineLoader)
|
||||
definitions.NewInput = inputFactory
|
||||
definitions.NewState = stateFactory
|
||||
definitions.Selectors = []commandkit.Flag[*inspectionInput]{campaignSelector()}
|
||||
definitions = ckprompt.WithRequestValidation(definitions, loadPromptEngine, buildValidationRequest)
|
||||
definitions = ckprompt.WithSmokeTesting(definitions, loadPromptEngine, nil)
|
||||
definitions = ckprompt.WithRequestValidation(definitions, engineLoader, buildValidationRequest)
|
||||
definitions = ckprompt.WithSmokeTesting(definitions, engineLoader, nil)
|
||||
if smokeTest != nil {
|
||||
definitions.SmokeTest = smokeTest
|
||||
}
|
||||
definitionCommand := ckprompt.Command(definitions)
|
||||
|
||||
store := map[string]session{
|
||||
@@ -246,36 +260,46 @@ func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
|
||||
Arguments: []commandkit.Argument[restoreInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "Session identifier."}, true, parseString,
|
||||
func(input *restoreInput, value commandkit.Supplied[string]) { input.Session = value.Value })},
|
||||
Run: func(invocation commandkit.Invocation, input restoreInput) commandkit.Outcome {
|
||||
reporter := invocation.Reporter()
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
return commandkit.Failure("Session restore was canceled.").WithCause(err)
|
||||
}
|
||||
application, err := loadApplicationConfig(invocation.Context, ckconfig.Selection{}, nil, nil)
|
||||
if err != nil {
|
||||
return commandkit.Failure(err)
|
||||
return commandkit.Failure("Could not load application configuration.").WithCause(err)
|
||||
}
|
||||
name, _, err := selectedCampaign(application.Config, input.Campaign)
|
||||
if err != nil {
|
||||
return commandkit.Failure(err)
|
||||
return commandkit.Failure(err.Error()).WithCause(err)
|
||||
}
|
||||
record, ok := store[input.Session]
|
||||
if !ok {
|
||||
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 4, Domain: "session_not_found", Cause: errors.New("saved session was not found")}
|
||||
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 4, Domain: "session_not_found", Cause: errSessionNotFound, Explanation: commandkit.Explanation{Message: "Saved session was not found."}}
|
||||
}
|
||||
if record.Campaign != name {
|
||||
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 3, Domain: "campaign_mismatch", Cause: fmt.Errorf("session belongs to campaign %q", record.Campaign)}
|
||||
}
|
||||
if _, err := fmt.Fprintf(invocation.Streams.Out, "restored %s: %s\n", record.ID, record.Summary); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
cause := fmt.Errorf("session belongs to campaign %q", record.Campaign)
|
||||
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 3, Domain: "campaign_mismatch", Cause: cause, Explanation: commandkit.Explanation{Message: "Session campaign does not match the selected campaign."}}
|
||||
}
|
||||
_ = reporter.RenderOutput(func(writer io.Writer) error {
|
||||
_, err := fmt.Fprintf(writer, "restored %s: %s\n", record.ID, record.Summary)
|
||||
return err
|
||||
})
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
return commandkit.New(
|
||||
commandkit.Identity{Name: "composed-example", Version: "1.0"},
|
||||
declarations := []commandkit.Declaration{
|
||||
configCommand,
|
||||
definitionCommand,
|
||||
commandkit.Completion("composed-example"),
|
||||
commandkit.Group("session", "Manage saved sessions.", restore),
|
||||
)
|
||||
commandkit.Alias("list collections", "promptkit list collections"),
|
||||
commandkit.Alias("promptkit list bundles", "promptkit list collections"),
|
||||
commandkit.Alias("pk", "promptkit"),
|
||||
commandkit.Alias("smoke", "promptkit test"),
|
||||
commandkit.FlagAliasFor("promptkit show prompt", "--fmt", "--format"),
|
||||
commandkit.FlagAlias("--hlep", "--help"),
|
||||
}
|
||||
return commandkit.New(commandkit.Identity{Name: "composed-example", Version: "1.0"}, declarations...)
|
||||
}
|
||||
|
||||
func parseString(raw string) (string, error) { return raw, nil }
|
||||
|
||||
@@ -3,15 +3,17 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"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"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/ckconfig"
|
||||
"go.maximumdirect.net/ckprompt"
|
||||
commandkit "go.maximumdirect.net/commandkit"
|
||||
"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 {
|
||||
t.Helper()
|
||||
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
|
||||
|
||||
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/promptkit v0.16.0
|
||||
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/spf13/cobra v1.10.1 // 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
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user