diff --git a/app.go b/app.go index d58379d..adb36e1 100644 --- a/app.go +++ b/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, }, }, diff --git a/binding_test.go b/binding_test.go index 6fcb66f..19a38d4 100644 --- a/binding_test.go +++ b/binding_test.go @@ -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 { diff --git a/builtins.go b/builtins.go index 13f3b62..538d323 100644 --- a/builtins.go +++ b/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 diff --git a/command.go b/command.go index 8977eeb..0a80a3e 100644 --- a/command.go +++ b/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 { diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index ac7a17b..52ae0be 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/execute.go b/execute.go index 7f047d8..9cb38c2 100644 --- a/execute.go +++ b/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 } } diff --git a/execute_external_test.go b/execute_external_test.go index a569015..aa56428 100644 --- a/execute_external_test.go +++ b/execute_external_test.go @@ -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()) - } -} diff --git a/go.mod b/go.mod index 893309d..7572384 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index ebbc341..12b4c7e 100644 --- a/go.sum +++ b/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= diff --git a/integrations/ckconfig/parent_help_test.go b/integrations/ckconfig/parent_help_test.go new file mode 100644 index 0000000..67500ce --- /dev/null +++ b/integrations/ckconfig/parent_help_test.go @@ -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()) + } +} diff --git a/outcome.go b/outcome.go index 10028e5..a822aaa 100644 --- a/outcome.go +++ b/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 { diff --git a/parser.go b/parser.go index f411e6e..4e55a17 100644 --- a/parser.go +++ b/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 diff --git a/parser_test.go b/parser_test.go index 12f934a..397102e 100644 --- a/parser_test.go +++ b/parser_test.go @@ -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) } diff --git a/performance_dispatch_test.go b/performance_dispatch_test.go index a8b8db3..a6b0a17 100644 --- a/performance_dispatch_test.go +++ b/performance_dispatch_test.go @@ -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 } diff --git a/registry.go b/registry.go index 3dd5a98..70e9c52 100644 --- a/registry.go +++ b/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 diff --git a/registry_test.go b/registry_test.go index 7b38a24..6c14d4c 100644 --- a/registry_test.go +++ b/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) } }