Capture immutable command declarations
This commit is contained in:
4
app.go
4
app.go
@@ -31,11 +31,11 @@ func New(identity Identity, commands ...Command) (*App, error) {
|
||||
identity: identity,
|
||||
commands: map[string]*commandNode{
|
||||
"help": {
|
||||
command: Command{name: "help", summary: "Show help for commands."},
|
||||
command: Command{record: &commandRecord{name: "help", summary: "Show help for commands."}},
|
||||
reserved: true,
|
||||
},
|
||||
"version": {
|
||||
command: Command{name: "version", summary: "Show application version."},
|
||||
command: Command{record: &commandRecord{name: "version", summary: "Show application version."}},
|
||||
reserved: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
||||
BoolFlag(FlagMeta{Name: "enabled"}, true, func(input *bindingInput, value Supplied[bool]) { input.enabled = value }),
|
||||
},
|
||||
}
|
||||
if _, err := NewCommand(spec); err != nil {
|
||||
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defaultMessage = "changed after declaration"
|
||||
@@ -163,7 +163,7 @@ func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) {
|
||||
RepeatArgument(ArgumentMeta{Name: "suffix"}, 1, -1, func(value string) (string, error) { return value, nil }, func(input *operandsInput, value Supplied[[]string]) { input.suffix = value }),
|
||||
},
|
||||
}
|
||||
if _, err := NewCommand(spec); err != nil {
|
||||
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepared, err := prepareInput(spec, nil, []string{"a", "b", "id-1", "x", "y"}, true)
|
||||
@@ -193,7 +193,7 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
|
||||
Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})},
|
||||
Arguments: []Argument[struct{}]{ValueArgument(ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})},
|
||||
}
|
||||
if _, err := NewCommand(spec); err != nil {
|
||||
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := prepareInput(spec, [][]string{nil}, nil, true); err == nil {
|
||||
|
||||
10
builtins.go
10
builtins.go
@@ -30,7 +30,7 @@ type versionDocument struct {
|
||||
}
|
||||
|
||||
func (a *App) installBuiltins() error {
|
||||
help, err := NewCommand(CommandSpec[helpInput]{
|
||||
help := NewCommand(CommandSpec[helpInput]{
|
||||
Name: "help",
|
||||
Summary: "Show help for commands.",
|
||||
Description: "Describe registered commands and their inputs.",
|
||||
@@ -41,12 +41,9 @@ func (a *App) installBuiltins() error {
|
||||
return a.renderHelpTargets(input.Targets.Value, invocation.Streams)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create built-in help command: %w", err)
|
||||
}
|
||||
scopeDefault := "app"
|
||||
formatDefault := "text"
|
||||
version, err := NewCommand(CommandSpec[versionInput]{
|
||||
version := NewCommand(CommandSpec[versionInput]{
|
||||
Name: "version",
|
||||
Summary: "Show application or CommandKit version.",
|
||||
Description: "Report identity and build metadata without loading application configuration.",
|
||||
@@ -65,9 +62,6 @@ func (a *App) installBuiltins() error {
|
||||
return renderVersionOutcome(identity, input.Format, invocation.Streams)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create built-in version command: %w", err)
|
||||
}
|
||||
a.commands["help"] = &commandNode{command: help, reserved: true}
|
||||
a.commands["version"] = &commandNode{command: version, reserved: true}
|
||||
return nil
|
||||
|
||||
172
command.go
172
command.go
@@ -1,6 +1,7 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -8,14 +9,20 @@ import (
|
||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||
)
|
||||
|
||||
// Command is an opaque command declaration. Use NewCommand or Group to create
|
||||
// declarations and WithChildren to compose them.
|
||||
type Command struct {
|
||||
// Command is an opaque, reusable declaration captured by NewCommand, Group,
|
||||
// RootGroup, or an integration.
|
||||
type Command struct{ record *commandRecord }
|
||||
|
||||
type commandRecord struct {
|
||||
name string
|
||||
summary string
|
||||
description string
|
||||
children []Command
|
||||
payload commandPayload
|
||||
issues []error
|
||||
rootOnly bool
|
||||
absent bool
|
||||
invalid bool
|
||||
}
|
||||
|
||||
// Supplied carries a value and whether it was explicitly provided.
|
||||
@@ -82,6 +89,8 @@ type CommandSpec[I any] struct {
|
||||
Summary string
|
||||
// Description provides additional command detail.
|
||||
Description string
|
||||
// Children are relative child declarations captured with this command.
|
||||
Children []Command
|
||||
// NewInput creates an input value for execution. A nil factory uses a fresh
|
||||
// zero value.
|
||||
NewInput func() I
|
||||
@@ -170,94 +179,109 @@ func (c typedCommand[I]) checkInput(input any) error {
|
||||
return c.spec.CheckInput(*input.(*I))
|
||||
}
|
||||
|
||||
// NewCommand validates and creates a typed command declaration. It retains
|
||||
// callbacks without invoking them and copies the declarative flag and argument
|
||||
// slices.
|
||||
func NewCommand[I any](spec CommandSpec[I]) (Command, error) {
|
||||
if err := validateCommandName(spec.Name); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
// NewCommand captures a typed command declaration. Construction issues are
|
||||
// retained and reported when the declaration is installed.
|
||||
func NewCommand[I any](spec CommandSpec[I]) Command {
|
||||
copySpec := spec
|
||||
copySpec.Flags = append([]Flag[I](nil), spec.Flags...)
|
||||
copySpec.Arguments = append([]Argument[I](nil), spec.Arguments...)
|
||||
copySpec.Children = append([]Command(nil), spec.Children...)
|
||||
payload := typedCommand[I]{spec: copySpec}
|
||||
record := &commandRecord{
|
||||
name: spec.Name, summary: spec.Summary, description: spec.Description,
|
||||
children: copySpec.Children, payload: payload,
|
||||
}
|
||||
if err := payload.validate(); err != nil {
|
||||
return Command{}, fmt.Errorf("command %q: %w", spec.Name, err)
|
||||
record.issues = append(record.issues, fmt.Errorf("command %q: %w", spec.Name, err))
|
||||
}
|
||||
return Command{
|
||||
name: spec.Name,
|
||||
summary: spec.Summary,
|
||||
description: spec.Description,
|
||||
payload: payload,
|
||||
}, nil
|
||||
captureComposition(record)
|
||||
return Command{record: record}
|
||||
}
|
||||
|
||||
// Group creates a non-runnable command group with the supplied children.
|
||||
func Group(name, summary string, children ...Command) (Command, error) {
|
||||
if err := validateCommandName(name); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
group := Command{name: name, summary: summary, children: cloneCommands(children)}
|
||||
if err := validateCommandDeclaration(group); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
return group, nil
|
||||
// Group captures a non-runnable command group and its relative children.
|
||||
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}
|
||||
}
|
||||
|
||||
// WithChildren returns a copy of parent with the supplied relative child
|
||||
// declarations. The parent's optional handler and input bindings are retained.
|
||||
func WithChildren(parent Command, children ...Command) (Command, error) {
|
||||
if err := validateCommandDeclaration(parent); err != nil {
|
||||
return Command{}, fmt.Errorf("parent command: %w", err)
|
||||
}
|
||||
combined := make([]Command, 0, len(parent.children)+len(children))
|
||||
combined = append(combined, parent.children...)
|
||||
combined = append(combined, children...)
|
||||
result := parent
|
||||
result.children = cloneCommands(combined)
|
||||
if err := validateCommandDeclaration(result); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func cloneCommands(commands []Command) []Command {
|
||||
if len(commands) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]Command, len(commands))
|
||||
for i, command := range commands {
|
||||
cloned[i] = cloneCommand(command)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneCommand(command Command) Command {
|
||||
command.children = cloneCommands(command.children)
|
||||
// RootGroup captures a group that may only be installed at the application root.
|
||||
func RootGroup(name, summary string, children ...Command) Command {
|
||||
command := Group(name, summary, children...)
|
||||
command.record.rootOnly = true
|
||||
return command
|
||||
}
|
||||
|
||||
func validateCommandDeclaration(command Command) error {
|
||||
if err := validateCommandName(command.name); err != nil {
|
||||
return err
|
||||
// WithError returns a declaration that also retains err for installation.
|
||||
func WithError(command Command, err error) Command {
|
||||
if err == nil {
|
||||
return command
|
||||
}
|
||||
if command.payload != nil {
|
||||
if err := command.payload.validate(); err != nil {
|
||||
return fmt.Errorf("command %q: %w", command.name, err)
|
||||
}
|
||||
var record commandRecord
|
||||
if command.record == nil {
|
||||
record.invalid = true
|
||||
} else {
|
||||
record = *command.record
|
||||
record.issues = append([]error(nil), record.issues...)
|
||||
}
|
||||
record.issues = append(record.issues, err)
|
||||
return Command{record: &record}
|
||||
}
|
||||
|
||||
children := make(map[string]struct{}, len(command.children))
|
||||
for _, child := range command.children {
|
||||
if err := validateCommandDeclaration(child); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := children[child.name]; exists {
|
||||
return fmt.Errorf("duplicate child command %q under %q", child.name, command.name)
|
||||
}
|
||||
children[child.name] = struct{}{}
|
||||
// NoCommand explicitly represents an absent optional declaration.
|
||||
func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
|
||||
|
||||
func captureComposition(record *commandRecord) {
|
||||
if err := validateCommandName(record.name); err != nil {
|
||||
record.issues = append(record.issues, err)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(record.children))
|
||||
for _, child := range record.children {
|
||||
if child.record == nil {
|
||||
record.issues = append(record.issues, fmt.Errorf("command %q has an invalid child declaration", record.name))
|
||||
continue
|
||||
}
|
||||
if child.record.absent {
|
||||
continue
|
||||
}
|
||||
name := child.record.name
|
||||
if _, exists := seen[name]; exists {
|
||||
record.issues = append(record.issues, fmt.Errorf("duplicate child command %q under %q", name, record.name))
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func validateCommandDeclaration(command Command) error {
|
||||
var issues []error
|
||||
collectDeclarationIssues(command, nil, &issues)
|
||||
return errors.Join(issues...)
|
||||
}
|
||||
|
||||
func collectDeclarationIssues(command Command, parents []string, issues *[]error) {
|
||||
if command.record == nil {
|
||||
*issues = append(*issues, errors.New("invalid zero command declaration"))
|
||||
return
|
||||
}
|
||||
record := command.record
|
||||
path := append(append([]string(nil), parents...), record.name)
|
||||
label := strings.Join(path, " ")
|
||||
for _, issue := range record.issues {
|
||||
if label != "" {
|
||||
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
|
||||
} else {
|
||||
*issues = append(*issues, issue)
|
||||
}
|
||||
}
|
||||
if record.invalid || record.absent {
|
||||
return
|
||||
}
|
||||
for _, child := range record.children {
|
||||
if child.record == nil {
|
||||
continue
|
||||
}
|
||||
collectDeclarationIssues(child, path, issues)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCommandName(name string) error {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Implement the optional integration modules and captured registration API
|
||||
|
||||
Status: implementation in progress. Stage 1 is complete; subsequent work follows the ordered roadmap.
|
||||
Status: implementation in progress. Stages 1 and 2 are complete; subsequent work follows the ordered roadmap.
|
||||
Execute the stages in order. Each stage is sized as a separate Luna coding-agent
|
||||
assignment. This document authorizes no publication or changes to sibling
|
||||
repositories.
|
||||
@@ -189,9 +189,13 @@ neutral diagnostic/outcome model.
|
||||
values, or general app pointer. App-specific built-ins are constructed for
|
||||
their app later; ordinary declarations remain reusable.
|
||||
|
||||
**Completion evidence:** focused capture/error checks cover source-slice edits,
|
||||
copied handles, prior/wrapped causes, root-only metadata, nested no-ops, invalid
|
||||
zero values, and runnable children. Do not promise arbitrary object deep copies.
|
||||
**Completion evidence — complete:** declarations now use immutable captured
|
||||
records; caller slices are copied at capture and child records are shared. Focused
|
||||
checks cover source-slice edits, copied handles, retained/wrapped causes, root-only
|
||||
metadata, nested no-ops, invalid zero values, and runnable parents with children.
|
||||
Diagnostics and `Acquired[T]` are core-owned, and ConfigKit/PromptKit requirements
|
||||
were removed from the root module. Root tests pass. This does not promise arbitrary
|
||||
object deep copies.
|
||||
|
||||
## Stage 3: Unify typed flag and argument conversion
|
||||
|
||||
|
||||
25
execute.go
25
execute.go
@@ -7,7 +7,6 @@ import (
|
||||
"io"
|
||||
|
||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// Execute parses and dispatches one invocation. args excludes the executable
|
||||
@@ -25,21 +24,21 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco
|
||||
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path)
|
||||
}
|
||||
command, ok := a.commandAt(parsed.Path)
|
||||
if !ok || command.payload == nil {
|
||||
if !ok || command.record == nil || command.record.payload == nil {
|
||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
||||
}
|
||||
if parsed.HelpRequested || !command.payload.hasRun() {
|
||||
if _, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
|
||||
if parsed.HelpRequested || !command.record.payload.hasRun() {
|
||||
if _, err := command.record.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)
|
||||
}
|
||||
buildInput, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, true)
|
||||
buildInput, err := command.record.payload.prepare(parsed.FlagValues, parsed.Operands, true)
|
||||
if err != nil {
|
||||
return current.present(Usage(err), parsed.Path)
|
||||
}
|
||||
input := buildInput()
|
||||
if err := command.payload.checkInput(input); err != nil {
|
||||
if err := command.record.payload.checkInput(input); err != nil {
|
||||
return current.present(Usage(err), parsed.Path)
|
||||
}
|
||||
if len(parsed.Path) == 1 && parsed.Path[0] == "help" {
|
||||
@@ -49,7 +48,7 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco
|
||||
}
|
||||
return current.present(current.renderHelpTargets(help.Targets.Value), parsed.Path)
|
||||
}
|
||||
outcome := command.payload.run(Invocation{Context: ctx, Streams: streams}, input)
|
||||
outcome := command.record.payload.run(Invocation{Context: ctx, Streams: streams}, input)
|
||||
return current.present(outcome, parsed.Path)
|
||||
}
|
||||
|
||||
@@ -144,14 +143,14 @@ func (a *App) commandAt(path []string) (Command, bool) {
|
||||
return Command{}, true
|
||||
}
|
||||
command, ok := a.commands[path[0]]
|
||||
if !ok || (command.reserved && command.command.payload == nil) {
|
||||
if !ok || (command.reserved && command.command.record.payload == nil) {
|
||||
return Command{}, false
|
||||
}
|
||||
current := command.command
|
||||
for _, name := range path[1:] {
|
||||
found := false
|
||||
for _, child := range current.children {
|
||||
if child.name == name {
|
||||
for _, child := range current.record.children {
|
||||
if child.record != nil && child.record.name == name {
|
||||
current = child
|
||||
found = true
|
||||
break
|
||||
@@ -183,10 +182,10 @@ func (a *App) presentWithUsageLine(outcome Outcome, path []string, streams Strea
|
||||
}
|
||||
diagnostics := append([]Diagnostic(nil), outcome.Diagnostics...)
|
||||
if outcome.Cause != nil && !hasErrorDiagnostic(diagnostics) {
|
||||
diagnostics = append(diagnostics, MessageDiagnostic(configkit.SeverityError, outcome.Cause.Error()))
|
||||
diagnostics = append(diagnostics, MessageDiagnostic(SeverityError, outcome.Cause.Error()))
|
||||
}
|
||||
if outcome.Kind == OutcomeUsage {
|
||||
diagnostics = append(diagnostics, MessageDiagnostic(configkit.SeverityError, usageLine(path)))
|
||||
diagnostics = append(diagnostics, MessageDiagnostic(SeverityError, usageLine(path)))
|
||||
}
|
||||
outcome.Diagnostics = diagnostics
|
||||
if len(diagnostics) == 0 {
|
||||
@@ -236,7 +235,7 @@ func (a *App) usageFallback() string {
|
||||
|
||||
func hasErrorDiagnostic(diagnostics []Diagnostic) bool {
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Write != nil && diagnostic.Severity == configkit.SeverityError {
|
||||
if diagnostic.Write != nil && diagnostic.Severity == SeverityError {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
@@ -24,7 +23,7 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
firstContext := context.WithValue(context.Background(), struct{}{}, "first")
|
||||
secondContext := context.WithValue(context.Background(), struct{}{}, "second")
|
||||
var firstOut, secondOut, firstDiagnostic, secondDiagnostic bytes.Buffer
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "run",
|
||||
Flags: []commandkit.Flag[input]{
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.name = value }),
|
||||
@@ -37,9 +36,6 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -70,7 +66,7 @@ func TestExecuteUsesFreshSessionForHelpAfterFlagBearingInvocation(t *testing.T)
|
||||
enabled commandkit.Supplied[bool]
|
||||
}
|
||||
var received []input
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "run",
|
||||
Flags: []commandkit.Flag[input]{
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) {
|
||||
@@ -83,9 +79,6 @@ func TestExecuteUsesFreshSessionForHelpAfterFlagBearingInvocation(t *testing.T)
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -115,14 +108,11 @@ func TestExecutePresentsHandlerUsageAfterRegisteringCommand(t *testing.T) {
|
||||
usageCause := errors.New("handler requests usage")
|
||||
var app *commandkit.App
|
||||
var calls int
|
||||
late, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
late := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "late",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
calls++
|
||||
@@ -132,10 +122,7 @@ func TestExecutePresentsHandlerUsageAfterRegisteringCommand(t *testing.T) {
|
||||
return commandkit.Usage(usageCause)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -156,7 +143,7 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
|
||||
type input struct{ count commandkit.Supplied[int] }
|
||||
parseFailure := errors.New("bad count")
|
||||
checkFailure := errors.New("incompatible inputs")
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "run",
|
||||
Flags: []commandkit.Flag[input]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Required: true}, nil, func(value string) (int, error) {
|
||||
if value == "bad" {
|
||||
@@ -168,9 +155,6 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
|
||||
CheckInput: func(input) error { return checkFailure },
|
||||
Run: func(commandkit.Invocation, input) commandkit.Outcome { return commandkit.Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -192,8 +176,8 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
|
||||
|
||||
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
domainCause := errors.New("domain rejected the request")
|
||||
warning := commandkit.MessageDiagnostic(configkit.SeverityWarning, "review needed")
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
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)
|
||||
@@ -201,9 +185,6 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
return outcome
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -216,7 +197,7 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
if diagnostic.String() != "review needed\ndomain rejected the request\n" || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
||||
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
|
||||
}
|
||||
warningOnly, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
warningOnly := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "warn",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
result := commandkit.Success()
|
||||
@@ -224,9 +205,6 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
return result
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -240,20 +218,17 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
|
||||
func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T) {
|
||||
cause := errors.New("already shown")
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
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: configkit.SeverityError, Write: func(writer io.Writer) error {
|
||||
outcome.Diagnostics = []commandkit.Diagnostic{{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "presented\n")
|
||||
return err
|
||||
}}}
|
||||
return outcome
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -267,10 +242,7 @@ func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T)
|
||||
diagnostic.Reset()
|
||||
completed := commandkit.Domain("sample", 2, cause)
|
||||
completed.DiagnosticDelivery = commandkit.DeliveryComplete
|
||||
command, err = commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "done", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return completed }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
@@ -284,7 +256,7 @@ func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T)
|
||||
func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
||||
operationFailure := context.Canceled
|
||||
writeFailure := errors.New("writer failed")
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(operationFailure)
|
||||
@@ -292,9 +264,6 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
||||
return outcome
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -313,7 +282,7 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
||||
func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) {
|
||||
type input struct{ id commandkit.Supplied[string] }
|
||||
factoryCalls, checkCalls, runCalls := 0, 0, 0
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "run",
|
||||
NewInput: func() input {
|
||||
factoryCalls++
|
||||
@@ -323,9 +292,6 @@ func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) {
|
||||
CheckInput: func(input) error { checkCalls++; return nil },
|
||||
Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -344,7 +310,7 @@ func TestExecuteRunsApplicationOwnedNestedHelp(t *testing.T) {
|
||||
type input struct{ topic commandkit.Supplied[string] }
|
||||
var received []input
|
||||
domainCause := errors.New("application help result")
|
||||
help, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
help := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "help",
|
||||
Description: "Explain a session topic.",
|
||||
Arguments: []commandkit.Argument[input]{commandkit.ValueArgument(
|
||||
@@ -357,13 +323,7 @@ func TestExecuteRunsApplicationOwnedNestedHelp(t *testing.T) {
|
||||
return commandkit.Domain("session-help", 7, domainCause)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := commandkit.Group("session", "Session commands.", help)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := commandkit.Group("session", "Session commands.", help)
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, session)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -401,7 +361,7 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
|
||||
tags commandkit.Supplied[[]string]
|
||||
}
|
||||
factoryCalls, runCalls := 0, 0
|
||||
show, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
show := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "show",
|
||||
Summary: "Show one item.",
|
||||
Description: "Display the selected item in detail.",
|
||||
@@ -418,25 +378,9 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
|
||||
Arguments: []commandkit.Argument[input]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "item identifier"}, 1, 3, func(value string) (string, error) { return value, nil }, func(*input, commandkit.Supplied[[]string]) {})},
|
||||
Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, err := commandkit.Group("config", "Inspect configuration.", show)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "now", Summary: "Deploy immediately.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deployParent, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "deploy", Summary: "Deploy artifacts.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { runCalls++; return commandkit.Success() }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deploy, err := commandkit.WithChildren(deployParent, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := commandkit.Group("config", "Inspect configuration.", show)
|
||||
now := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "now", Summary: "Deploy immediately.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})
|
||||
deploy := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "deploy", Summary: "Deploy artifacts.", Children: []commandkit.Command{now}, Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { runCalls++; return commandkit.Success() }})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, config, deploy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -559,10 +503,7 @@ func (shortWriter) Write(data []byte) (int, error) {
|
||||
}
|
||||
|
||||
func TestHelpEntryFormsPresentReportAndDiagnosticFailures(t *testing.T) {
|
||||
group, err := commandkit.Group("notes", "Notes metadata.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group := commandkit.Group("notes", "Notes metadata.")
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, group)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -595,10 +536,7 @@ func TestExecuteNormalizesOutcomeDefaultsAndPreservesDomainValues(t *testing.T)
|
||||
{commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 2, Domain: "needs action", Cause: cause}, commandkit.OutcomeDomain, 2, "needs action", cause},
|
||||
{commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 0, Domain: "complete"}, commandkit.OutcomeDomain, 0, "complete", nil},
|
||||
} {
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return tc.outcome }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return tc.outcome }})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -617,11 +555,8 @@ func TestExecuteNormalizesOutcomeDefaultsAndPreservesDomainValues(t *testing.T)
|
||||
func TestNilDiagnosticWritersHaveNoOutputAndDoNotHideCauses(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: configkit.SeverityWarning}, {Severity: configkit.SeverityError}}
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return base }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base.Diagnostics = []commandkit.Diagnostic{{}, {Severity: commandkit.SeverityWarning}, {Severity: commandkit.SeverityError}}
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return base }})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -649,50 +584,3 @@ func TestNilDiagnosticWritersHaveNoOutputAndDoNotHideCauses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentHelpDiscoversMetadataOnlyGroups(t *testing.T) {
|
||||
leaf, err := commandkit.Group("notes", "Read notes metadata.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent, err := commandkit.Group("topics", "Browse topic metadata.", leaf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, parent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loads := 0
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
loads++
|
||||
return commandkit.Acquired[commandkit.ConfigView]{}, nil
|
||||
}}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
content string
|
||||
}{
|
||||
{nil, "Browse topic metadata."},
|
||||
{[]string{"topics"}, "Read notes metadata."},
|
||||
{[]string{"topics", "--help"}, "Read notes metadata."},
|
||||
{[]string{"help", "topics"}, "Read notes metadata."},
|
||||
{[]string{"topics", "notes"}, "Read notes metadata."},
|
||||
{[]string{"help", "topics", "notes"}, "Read notes metadata."},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
result := app.Execute(context.Background(), tc.args, commandkit.Streams{Out: &out})
|
||||
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), tc.content) || loads != 0 {
|
||||
t.Fatalf("%v: result=%#v output=%q loads=%d", tc.args, result, out.String(), loads)
|
||||
}
|
||||
if len(tc.args) == 1 && (!strings.Contains(out.String(), "Available Commands:") || !strings.Contains(out.String(), "[command]")) {
|
||||
t.Fatalf("missing child inventory/usage: %q", out.String())
|
||||
}
|
||||
}
|
||||
var diagnostic bytes.Buffer
|
||||
result := app.Execute(context.Background(), []string{"topics", "unknown"}, commandkit.Streams{Err: &diagnostic})
|
||||
if result.Kind != commandkit.OutcomeUsage || !strings.Contains(diagnostic.String(), "[command]") {
|
||||
t.Fatalf("result=%#v diagnostic=%q", result, diagnostic.String())
|
||||
}
|
||||
}
|
||||
|
||||
6
go.mod
6
go.mod
@@ -3,8 +3,6 @@ module gitea.maximumdirect.net/eric/CommandKit
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.5.0
|
||||
gitea.maximumdirect.net/eric/promptkit v0.12.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/pflag v1.0.9
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
@@ -12,13 +10,9 @@ require (
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0 // indirect
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0 // indirect
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
|
||||
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||
|
||||
15
go.sum
15
go.sum
@@ -1,21 +1,9 @@
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.5.0 h1:2KwG10+kApWM3avOoYA+p768xcbHUp7tQ2rqizBNpyg=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.5.0/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0 h1:aTMn+50MEpHpHI2RoCCmJYsy7Ohrpa+RSva63Kq0/tU=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||
gitea.maximumdirect.net/eric/promptkit v0.12.0 h1:oIFWEg6GPxtmX3Plqv0euP+FnK1LCk/t4xDy6oKCpP4=
|
||||
gitea.maximumdirect.net/eric/promptkit v0.12.0/go.mod h1:pFOOy5v1zH17stEi9rmHYmfbuLjnfVMcUhfmk+4VtJY=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0 h1:lzfCDbzJQRGzIlRPRprnAV9hVVL5zUQy80VMQ0kbYa0=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0/go.mod h1:HjS0f+3q0IdHCTEz+VVAEgIwIWJFAZUjuKdAtvtqKdg=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 h1:h12ZTRldAmpLAomj0oQRGteFxHLN1LPE6XDl9zJNbm8=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0/go.mod h1:OEFBeKdjTsLzqJwLmXI2O5gG3r64FktcVJVb3mBrRfg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
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=
|
||||
@@ -26,8 +14,5 @@ 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=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
52
integrations/ckconfig/parent_help_test.go
Normal file
52
integrations/ckconfig/parent_help_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package ckconfig_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
)
|
||||
|
||||
func TestParentHelpDiscoversMetadataOnlyGroups(t *testing.T) {
|
||||
leaf := commandkit.Group("notes", "Read notes metadata.")
|
||||
parent := commandkit.Group("topics", "Browse topic metadata.", leaf)
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, parent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loads := 0
|
||||
if err := ckconfig.RegisterConfig(app, ckconfig.ConfigSuite[struct{}]{Scopes: []ckconfig.ConfigScope[struct{}]{{Name: "app", Operations: []ckconfig.ConfigOperation{ckconfig.ConfigValidate}, Acquire: func(context.Context, ckconfig.ConfigRequest, struct{}) (commandkit.Acquired[ckconfig.ConfigView], error) {
|
||||
loads++
|
||||
return commandkit.Acquired[ckconfig.ConfigView]{}, nil
|
||||
}}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
content string
|
||||
}{
|
||||
{nil, "Browse topic metadata."},
|
||||
{[]string{"topics"}, "Read notes metadata."},
|
||||
{[]string{"topics", "--help"}, "Read notes metadata."},
|
||||
{[]string{"help", "topics"}, "Read notes metadata."},
|
||||
{[]string{"topics", "notes"}, "Read notes metadata."},
|
||||
{[]string{"help", "topics", "notes"}, "Read notes metadata."},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
result := app.Execute(context.Background(), tc.args, commandkit.Streams{Out: &out})
|
||||
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), tc.content) || loads != 0 {
|
||||
t.Fatalf("%v: result=%#v output=%q loads=%d", tc.args, result, out.String(), loads)
|
||||
}
|
||||
if len(tc.args) == 1 && (!strings.Contains(out.String(), "Available Commands:") || !strings.Contains(out.String(), "[command]")) {
|
||||
t.Fatalf("missing child inventory/usage: %q", out.String())
|
||||
}
|
||||
}
|
||||
var diagnostic bytes.Buffer
|
||||
result := app.Execute(context.Background(), []string{"topics", "unknown"}, commandkit.Streams{Err: &diagnostic})
|
||||
if result.Kind != commandkit.OutcomeUsage || !strings.Contains(diagnostic.String(), "[command]") {
|
||||
t.Fatalf("result=%#v diagnostic=%q", result, diagnostic.String())
|
||||
}
|
||||
}
|
||||
22
outcome.go
22
outcome.go
@@ -3,8 +3,16 @@ package commandkit
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
// Severity classifies a neutral diagnostic.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
// SeverityWarning indicates information that does not fail the operation.
|
||||
SeverityWarning Severity = "warning"
|
||||
// SeverityError indicates a diagnostic associated with an error.
|
||||
SeverityError Severity = "error"
|
||||
)
|
||||
|
||||
// OutcomeKind classifies the result of a command invocation.
|
||||
@@ -38,12 +46,18 @@ const (
|
||||
// Write is called by CommandKit's diagnostic delivery path. It should return
|
||||
// any error encountered while writing. A nil Write function has no output.
|
||||
type Diagnostic struct {
|
||||
// Severity classifies the diagnostic using ConfigKit's public severity type.
|
||||
Severity configkit.Severity
|
||||
// Severity classifies the diagnostic without depending on a provider library.
|
||||
Severity Severity
|
||||
// Write writes the diagnostic to the supplied writer.
|
||||
Write func(io.Writer) error
|
||||
}
|
||||
|
||||
// Acquired pairs a provider value with diagnostics emitted during acquisition.
|
||||
type Acquired[T any] struct {
|
||||
Value T
|
||||
Diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
// Outcome is the classified result of an operation.
|
||||
//
|
||||
// Cause records the operation error. DeliveryError records an error encountered
|
||||
@@ -95,7 +109,7 @@ func Domain(name string, status int, cause error) Outcome {
|
||||
|
||||
// MessageDiagnostic returns a diagnostic that writes message followed by a
|
||||
// newline. The writer's error is returned to the caller.
|
||||
func MessageDiagnostic(severity configkit.Severity, message string) Diagnostic {
|
||||
func MessageDiagnostic(severity Severity, message string) Diagnostic {
|
||||
return Diagnostic{
|
||||
Severity: severity,
|
||||
Write: func(w io.Writer) error {
|
||||
|
||||
27
parser.go
27
parser.go
@@ -27,7 +27,10 @@ func (a *App) buildBridgeTree() *internalcobra.Command {
|
||||
root := &internalcobra.Command{Name: a.identity.Name}
|
||||
names := make([]string, 0, len(a.commands))
|
||||
for name, node := range a.commands {
|
||||
if !node.reserved || node.command.payload != nil {
|
||||
if node.command.record != nil && node.command.record.absent {
|
||||
continue
|
||||
}
|
||||
if !node.reserved || node.command.record.payload != nil {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
@@ -39,19 +42,23 @@ func (a *App) buildBridgeTree() *internalcobra.Command {
|
||||
}
|
||||
|
||||
func bridgeCommand(command Command) *internalcobra.Command {
|
||||
record := command.record
|
||||
declaration := &internalcobra.Command{
|
||||
Name: command.name,
|
||||
Summary: command.summary,
|
||||
Description: command.description,
|
||||
Name: record.name,
|
||||
Summary: record.summary,
|
||||
Description: record.description,
|
||||
}
|
||||
if command.payload != nil {
|
||||
declaration.Runnable = command.payload.hasRun()
|
||||
declaration.Flags = command.payload.bridgeFlags()
|
||||
declaration.Arguments = command.payload.bridgeArguments()
|
||||
if record.payload != nil {
|
||||
declaration.Runnable = record.payload.hasRun()
|
||||
declaration.Flags = record.payload.bridgeFlags()
|
||||
declaration.Arguments = record.payload.bridgeArguments()
|
||||
}
|
||||
children := append([]Command(nil), command.children...)
|
||||
sort.Slice(children, func(i, j int) bool { return children[i].name < children[j].name })
|
||||
children := append([]Command(nil), record.children...)
|
||||
sort.Slice(children, func(i, j int) bool { return children[i].record.name < children[j].record.name })
|
||||
for _, child := range children {
|
||||
if child.record == nil || child.record.absent {
|
||||
continue
|
||||
}
|
||||
declaration.Children = append(declaration.Children, bridgeCommand(child))
|
||||
}
|
||||
return declaration
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"testing"
|
||||
|
||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) {
|
||||
@@ -17,7 +16,7 @@ func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) {
|
||||
scope Supplied[string]
|
||||
id Supplied[string]
|
||||
}
|
||||
show, err := NewCommand(CommandSpec[input]{
|
||||
show := NewCommand(CommandSpec[input]{
|
||||
Name: "show",
|
||||
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "scope"}, (*string)(nil), func(value string) (string, error) {
|
||||
return value, nil
|
||||
@@ -27,13 +26,8 @@ func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) {
|
||||
}, func(value *input, supplied Supplied[string]) { value.id = supplied })},
|
||||
Run: func(Invocation, input) Outcome { return Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, err := Group("config", "configuration", show)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := Group("config", "configuration", show)
|
||||
var err error
|
||||
app, err := New(Identity{Name: "example"}, config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -54,13 +48,10 @@ func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecutionRefreshesHelpMetadataAfterRegistration(t *testing.T) {
|
||||
first, err := NewCommand(CommandSpec[struct{}]{
|
||||
first := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "first",
|
||||
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := New(Identity{Name: "example"}, first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -72,14 +63,11 @@ func TestExecutionRefreshesHelpMetadataAfterRegistration(t *testing.T) {
|
||||
t.Fatalf("initial parse failed: %s", parsed.Failure.Message)
|
||||
}
|
||||
|
||||
late, err := NewCommand(CommandSpec[struct{}]{
|
||||
late := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "late",
|
||||
Summary: "registered during execution",
|
||||
Run: func(Invocation, struct{}) Outcome { return Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Register(late); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -127,10 +127,7 @@ func performanceFlatCommands(tb testing.TB, count int, prefix string) []commandk
|
||||
return commandkit.Usage(performanceUsageCause)
|
||||
}
|
||||
}
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: name, Summary: "Benchmark command.", Flags: flags, Run: run})
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: name, Summary: "Benchmark command.", Flags: flags, Run: run})
|
||||
commands = append(commands, command)
|
||||
}
|
||||
return commands
|
||||
@@ -142,20 +139,11 @@ func performanceNestedCommand(tb testing.TB) (commandkit.Command, []string) {
|
||||
for index := range path {
|
||||
path[index] = fmt.Sprintf("level%02d", index)
|
||||
}
|
||||
leaf, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: path[len(path)-1], Summary: "Nested benchmark command.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
leaf := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: path[len(path)-1], Summary: "Nested benchmark command.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})
|
||||
current := leaf
|
||||
for index := len(path) - 2; index >= 0; index-- {
|
||||
sibling, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: fmt.Sprintf("sibling%02d", index), Summary: "Sibling command.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
current, err = commandkit.Group(path[index], "Nested benchmark group.", current, sibling)
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
sibling := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: fmt.Sprintf("sibling%02d", index), Summary: "Sibling command.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})
|
||||
current = commandkit.Group(path[index], "Nested benchmark group.", current, sibling)
|
||||
}
|
||||
return current, path
|
||||
}
|
||||
|
||||
14
registry.go
14
registry.go
@@ -14,13 +14,17 @@ func (a *App) Register(commands ...Command) error {
|
||||
if err := validateCommandDeclaration(command); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := a.commands[command.name]; exists {
|
||||
return fmt.Errorf("command path %q is already registered", command.name)
|
||||
if command.record.absent {
|
||||
continue
|
||||
}
|
||||
if _, exists := pending[command.name]; exists {
|
||||
return fmt.Errorf("command path %q is already registered", command.name)
|
||||
name := command.record.name
|
||||
if _, exists := a.commands[name]; exists {
|
||||
return fmt.Errorf("command path %q is already registered", name)
|
||||
}
|
||||
pending[command.name] = &commandNode{command: cloneCommand(command)}
|
||||
if _, exists := pending[name]; exists {
|
||||
return fmt.Errorf("command path %q is already registered", name)
|
||||
}
|
||||
pending[name] = &commandNode{command: command}
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
|
||||
275
registry_test.go
275
registry_test.go
@@ -1,46 +1,29 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type registryInput struct {
|
||||
value string
|
||||
}
|
||||
type registryInput struct{ value string }
|
||||
|
||||
func TestNewAndRegisterRetainCallbacksWithoutCallingThem(t *testing.T) {
|
||||
func TestCapturedDeclarationsRetainCallbacksWithoutCallingThem(t *testing.T) {
|
||||
var factoryCalls, prepareCalls, setterCalls, checkCalls, runCalls int
|
||||
flag := Flag[registryInput]{binding: flagBinding[registryInput]{
|
||||
meta: FlagMeta{Name: "mode"},
|
||||
valid: true,
|
||||
meta: FlagMeta{Name: "mode"}, valid: true,
|
||||
prepare: func([]string) (func(*registryInput), error) {
|
||||
prepareCalls++
|
||||
return func(*registryInput) { setterCalls++ }, nil
|
||||
},
|
||||
}}
|
||||
command, err := NewCommand(CommandSpec[registryInput]{
|
||||
Name: "Inspect",
|
||||
NewInput: func() registryInput {
|
||||
factoryCalls++
|
||||
return registryInput{}
|
||||
},
|
||||
Flags: []Flag[registryInput]{flag},
|
||||
CheckInput: func(registryInput) error {
|
||||
checkCalls++
|
||||
return nil
|
||||
},
|
||||
Run: func(Invocation, registryInput) Outcome {
|
||||
runCalls++
|
||||
return Success()
|
||||
},
|
||||
command := NewCommand(CommandSpec[registryInput]{
|
||||
Name: "Inspect",
|
||||
NewInput: func() registryInput { factoryCalls++; return registryInput{} },
|
||||
Flags: []Flag[registryInput]{flag},
|
||||
CheckInput: func(registryInput) error { checkCalls++; return nil },
|
||||
Run: func(Invocation, registryInput) Outcome { runCalls++; return Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group, err := Group("Tools", "tools", command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group := Group("Tools", "tools", command)
|
||||
app, err := New(Identity{Name: "Example"}, group)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -48,11 +31,10 @@ func TestNewAndRegisterRetainCallbacksWithoutCallingThem(t *testing.T) {
|
||||
if err := app.Register(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if factoryCalls+prepareCalls+setterCalls+checkCalls+runCalls != 0 {
|
||||
t.Fatalf("construction invoked a callback: factory=%d prepare=%d setter=%d check=%d run=%d", factoryCalls, prepareCalls, setterCalls, checkCalls, runCalls)
|
||||
t.Fatalf("construction invoked callback: factory=%d prepare=%d setter=%d check=%d run=%d", factoryCalls, prepareCalls, setterCalls, checkCalls, runCalls)
|
||||
}
|
||||
if !app.commands["Tools"].command.children[0].payload.hasRun() {
|
||||
if !app.commands["Tools"].command.record.children[0].record.payload.hasRun() {
|
||||
t.Fatal("registered command lost its handler")
|
||||
}
|
||||
}
|
||||
@@ -66,10 +48,7 @@ func TestAppsHaveIndependentRegistries(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command, err := NewCommand(CommandSpec[struct{}]{Name: "custom"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := NewCommand(CommandSpec[struct{}]{Name: "custom"})
|
||||
if err := first.Register(command); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -81,152 +60,126 @@ func TestAppsHaveIndependentRegistries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandAndGroupNames(t *testing.T) {
|
||||
func TestDeclarationIssuesAreReportedAtInstallation(t *testing.T) {
|
||||
for _, name := range []string{"", "--", "-option", "two words", " leading", "trailing "} {
|
||||
if _, err := NewCommand(CommandSpec[struct{}]{Name: name}); err == nil {
|
||||
t.Errorf("NewCommand accepted invalid name %q", name)
|
||||
app, err := New(Identity{Name: "Example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Register(NewCommand(CommandSpec[struct{}]{Name: name})); err == nil {
|
||||
t.Errorf("installation accepted invalid name %q", name)
|
||||
}
|
||||
}
|
||||
if _, err := NewCommand(CommandSpec[struct{}]{Name: "CamelCase"}); err != nil {
|
||||
t.Fatalf("NewCommand rejected a custom mixed-case name: %v", err)
|
||||
}
|
||||
|
||||
first, err := NewCommand(CommandSpec[struct{}]{Name: "repeat"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := NewCommand(CommandSpec[struct{}]{Name: "repeat"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Group("parent", "", first, second); err == nil {
|
||||
t.Fatal("Group accepted duplicate sibling paths")
|
||||
}
|
||||
if _, err := Group("parent", "", Command{}); err == nil {
|
||||
t.Fatal("Group accepted an invalid child declaration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithChildrenRetainsRunnableParent(t *testing.T) {
|
||||
parent, err := NewCommand(CommandSpec[struct{}]{Name: "parent", Run: func(Invocation, struct{}) Outcome {
|
||||
return Success()
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child, err := NewCommand(CommandSpec[struct{}]{Name: "child"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
composed, err := WithChildren(parent, child)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if composed.payload == nil || !composed.payload.hasRun() {
|
||||
t.Fatal("WithChildren did not retain the runnable parent")
|
||||
}
|
||||
if len(composed.children) != 1 || composed.children[0].name != "child" {
|
||||
t.Fatalf("children = %#v, want relative child declaration", composed.children)
|
||||
}
|
||||
if len(parent.children) != 0 {
|
||||
t.Fatal("WithChildren changed the original parent declaration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBatchIsAtomicAndRejectsReservedPaths(t *testing.T) {
|
||||
app, err := New(Identity{Name: "Example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing, err := NewCommand(CommandSpec[struct{}]{Name: "existing"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if err := app.Register(NewCommand(CommandSpec[struct{}]{Name: "CamelCase"})); err != nil {
|
||||
t.Fatalf("mixed-case name rejected: %v", err)
|
||||
}
|
||||
if err := app.Register(existing); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
duplicate := Group("parent", "", NewCommand(CommandSpec[struct{}]{Name: "repeat"}), NewCommand(CommandSpec[struct{}]{Name: "repeat"}))
|
||||
if err := app.Register(duplicate); err == nil {
|
||||
t.Fatal("installation accepted duplicate sibling names")
|
||||
}
|
||||
before := app.commands["existing"]
|
||||
added, err := NewCommand(CommandSpec[struct{}]{Name: "added"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if err := app.Register(Group("parent", "", Command{})); err == nil {
|
||||
t.Fatal("installation accepted a zero child handle")
|
||||
}
|
||||
if err := app.Register(added, existing); err == nil {
|
||||
t.Fatal("Register accepted a duplicate path")
|
||||
}
|
||||
if app.commands["existing"] != before {
|
||||
t.Fatal("failed batch changed an existing registration")
|
||||
}
|
||||
if _, exists := app.commands["added"]; exists {
|
||||
t.Fatal("failed batch attached a partial registration")
|
||||
}
|
||||
if err := app.Register(added, added); err == nil {
|
||||
t.Fatal("Register accepted a duplicate within a batch")
|
||||
}
|
||||
if _, exists := app.commands["added"]; exists {
|
||||
t.Fatal("failed duplicate batch attached a registration")
|
||||
}
|
||||
for _, name := range []string{"help", "version"} {
|
||||
reserved, err := NewCommand(CommandSpec[struct{}]{Name: name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Register(reserved); err == nil {
|
||||
t.Errorf("Register replaced reserved command %q", name)
|
||||
}
|
||||
if err := app.Register(Command{}); err == nil {
|
||||
t.Fatal("installation accepted a zero command handle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsEmptyIdentityAndInvalidBindingMetadata(t *testing.T) {
|
||||
func TestRunnableCommandCapturesChildrenAndLeavesOriginalUnchanged(t *testing.T) {
|
||||
child := NewCommand(CommandSpec[struct{}]{Name: "child"})
|
||||
children := []Command{child}
|
||||
parent := NewCommand(CommandSpec[struct{}]{Name: "parent", Children: children, Run: func(Invocation, struct{}) Outcome { return Success() }})
|
||||
children[0] = NewCommand(CommandSpec[struct{}]{Name: "changed"})
|
||||
if parent.record.payload == nil || !parent.record.payload.hasRun() {
|
||||
t.Fatal("runnable parent lost its handler")
|
||||
}
|
||||
if len(parent.record.children) != 1 || parent.record.children[0].record.name != "child" {
|
||||
t.Fatalf("captured children = %#v", parent.record.children)
|
||||
}
|
||||
app, err := New(Identity{Name: "Example"}, parent)
|
||||
if err != nil {
|
||||
t.Fatalf("runnable parent with children rejected: %v", err)
|
||||
}
|
||||
if _, ok := app.commandAt([]string{"parent", "child"}); !ok {
|
||||
t.Fatal("child declaration was not installed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupCapturesChildrenAndSkipsNestedAbsence(t *testing.T) {
|
||||
children := []Command{NewCommand(CommandSpec[struct{}]{Name: "visible"}), NoCommand()}
|
||||
group := Group("parent", "", children...)
|
||||
children[0] = NewCommand(CommandSpec[struct{}]{Name: "changed"})
|
||||
if len(group.record.children) != 2 || group.record.children[0].record.name != "visible" {
|
||||
t.Fatalf("group did not capture child handles: %#v", group.record.children)
|
||||
}
|
||||
app, err := New(Identity{Name: "Example"}, group)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := app.commandAt([]string{"parent", "visible"}); !ok {
|
||||
t.Fatal("captured child missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootGroupRetainsPlacementMetadata(t *testing.T) {
|
||||
root := RootGroup("config", "Configuration.")
|
||||
if !root.record.rootOnly || root.record.absent || root.record.name != "config" {
|
||||
t.Fatalf("root group metadata = %#v", root.record)
|
||||
}
|
||||
copy := root
|
||||
if copy.record != root.record {
|
||||
t.Fatal("copied handle did not retain captured record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithErrorCopiesDeclarationAndRetainsWrappedCauses(t *testing.T) {
|
||||
first := errors.New("first construction issue")
|
||||
second := errors.New("second construction issue")
|
||||
base := NewCommand(CommandSpec[struct{}]{Name: "run"})
|
||||
withFirst := WithError(base, first)
|
||||
withBoth := WithError(withFirst, errors.Join(errors.New("wrapper"), second))
|
||||
if len(base.record.issues) != 0 {
|
||||
t.Fatal("WithError mutated its source declaration")
|
||||
}
|
||||
if len(withFirst.record.issues) != 1 || len(withBoth.record.issues) != 2 {
|
||||
t.Fatal("WithError did not retain prior causes")
|
||||
}
|
||||
app, err := New(Identity{Name: "Example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = app.Register(withBoth)
|
||||
if !errors.Is(err, first) || !errors.Is(err, second) {
|
||||
t.Fatalf("installation error %v did not preserve causes", err)
|
||||
}
|
||||
if err := app.Register(WithError(NoCommand(), first)); !errors.Is(err, first) {
|
||||
t.Fatalf("error on absence = %v", err)
|
||||
}
|
||||
if err := app.Register(NoCommand()); err != nil {
|
||||
t.Fatalf("plain absence was not skipped: %v", err)
|
||||
}
|
||||
if err := app.Register(RootGroup("empty", "")); err != nil {
|
||||
t.Fatalf("real empty root group rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsEmptyIdentity(t *testing.T) {
|
||||
if _, err := New(Identity{}); err == nil {
|
||||
t.Fatal("New accepted an empty identity name")
|
||||
}
|
||||
|
||||
validFlag := func(name, short string) Flag[registryInput] {
|
||||
return Flag[registryInput]{binding: flagBinding[registryInput]{
|
||||
meta: FlagMeta{Name: name, Short: short},
|
||||
valid: true,
|
||||
prepare: func([]string) (func(*registryInput), error) { return func(*registryInput) {}, nil },
|
||||
}}
|
||||
}
|
||||
for _, flags := range [][]Flag[registryInput]{
|
||||
{validFlag("one", "x"), validFlag("one", "y")},
|
||||
{validFlag("one", "x"), validFlag("two", "x")},
|
||||
{validFlag("help", "")},
|
||||
{validFlag("SomeFlag", "")},
|
||||
{validFlag("some_flag", "")},
|
||||
{validFlag("some--flag", "")},
|
||||
{{binding: flagBinding[registryInput]{meta: FlagMeta{Name: "missing"}}}},
|
||||
} {
|
||||
if _, err := NewCommand(CommandSpec[registryInput]{Name: "flags", Flags: flags}); err == nil {
|
||||
t.Errorf("NewCommand accepted invalid flag metadata: %#v", flags)
|
||||
}
|
||||
}
|
||||
|
||||
badArgument := Argument[registryInput]{binding: argumentBinding[registryInput]{
|
||||
meta: ArgumentMeta{Name: "item"},
|
||||
min: 2,
|
||||
max: 1,
|
||||
valid: true,
|
||||
prepare: func([]string) (func(*registryInput), error) { return func(*registryInput) {}, nil },
|
||||
}}
|
||||
if _, err := NewCommand(CommandSpec[registryInput]{Name: "args", Arguments: []Argument[registryInput]{badArgument}}); err == nil {
|
||||
t.Fatal("NewCommand accepted invalid argument bounds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommandCopiesBindingMetadata(t *testing.T) {
|
||||
flags := []Flag[registryInput]{{binding: flagBinding[registryInput]{
|
||||
meta: FlagMeta{Name: "mode"},
|
||||
valid: true,
|
||||
prepare: func([]string) (func(*registryInput), error) { return func(*registryInput) {}, nil },
|
||||
}}}
|
||||
command, err := NewCommand(CommandSpec[registryInput]{Name: "run", Flags: flags})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
flags := []Flag[registryInput]{{binding: flagBinding[registryInput]{meta: FlagMeta{Name: "mode"}, valid: true, prepare: func([]string) (func(*registryInput), error) { return func(*registryInput) {}, nil }}}}
|
||||
command := NewCommand(CommandSpec[registryInput]{Name: "run", Flags: flags})
|
||||
flags[0].binding.meta.Name = "changed"
|
||||
payload := command.payload.(typedCommand[registryInput])
|
||||
payload := command.record.payload.(typedCommand[registryInput])
|
||||
if got := payload.spec.Flags[0].binding.meta.Name; got != "mode" {
|
||||
t.Fatalf("stored flag name = %q, want %q", got, "mode")
|
||||
t.Fatalf("stored flag name = %q, want mode", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user