Capture immutable command declarations

This commit is contained in:
2026-10-11 01:15:13 +00:00
parent 476e98a463
commit de9bfdcc4c
16 changed files with 368 additions and 474 deletions

4
app.go
View File

@@ -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,
},
},

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View 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())
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}
}