diff --git a/app.go b/app.go index 65f541c..9af7e31 100644 --- a/app.go +++ b/app.go @@ -3,14 +3,19 @@ package commandkit import ( "fmt" - internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" ) // App owns one command registry and its application identity. type App struct { - identity Identity - commands map[string]*commandNode - commandTree *internalcobra.Command + identity Identity + commands map[string]*commandNode + snapshot *routingSnapshot +} + +type routingSnapshot struct { + metadata *commandmeta.Command + runners map[commandmeta.Key]commandPayload } type commandNode struct { diff --git a/builtins.go b/builtins.go index 65ba063..f597d5f 100644 --- a/builtins.go +++ b/builtins.go @@ -6,9 +6,9 @@ import ( "fmt" "io" "runtime/debug" - "strings" internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" yaml "go.yaml.in/yaml/v4" ) @@ -79,31 +79,25 @@ func parseVersionFormat(value string) (string, error) { } func (a *App) renderHelpOutcome(path []string, streams Streams) Outcome { - session := internalcobra.Prepare(a.bridgeTree(), internalcobra.ParseOptions{}) + session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{}) return a.renderHelpOutcomeWithSession(path, streams, session) } func (a *App) renderHelpTargets(targets []string, streams Streams) Outcome { - session := internalcobra.Prepare(a.bridgeTree(), internalcobra.ParseOptions{}) + session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{}) return a.renderHelpTargetsWithSession(targets, streams, session) } func (a *App) renderHelpTargetsWithSession(targets []string, streams Streams, session *internalcobra.Session) Outcome { - target, err := a.helpCommandTarget(targets) - if err != nil { - return Usage(err) - } - return a.renderHelpOutcomeWithSession(target, streams, session) + return a.renderHelpOutcomeWithSession(targets, streams, session) } func (a *App) renderHelpOutcomeWithSession(path []string, streams Streams, session *internalcobra.Session) Outcome { - if len(path) > 0 { - if _, ok := a.commandAt(path); !ok { - return Usage(fmt.Errorf("unknown help path %q", strings.Join(path, " "))) - } - } content, failure := session.RenderHelp(path) if failure != nil { + if failure.Usage { + return Usage(errors.New(failure.Message)) + } return Failure(errors.New(failure.Message)) } if err := writeReport(streams.Out, []byte(content)); err != nil { @@ -201,13 +195,3 @@ func commandKitBuildIdentity(info *debug.BuildInfo) Identity { } return identity } - -func (a *App) helpCommandTarget(targets []string) ([]string, error) { - if len(targets) == 0 { - return nil, nil - } - if _, ok := a.commandAt(targets); !ok { - return nil, fmt.Errorf("unknown help path %q", strings.Join(targets, " ")) - } - return append([]string(nil), targets...), nil -} diff --git a/command.go b/command.go index c1fc3c4..f6bf09d 100644 --- a/command.go +++ b/command.go @@ -6,7 +6,7 @@ import ( "strings" "unicode" - internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" ) // Command is an opaque, reusable declaration captured by NewCommand, Group, @@ -117,8 +117,8 @@ type CommandSpec[I any] struct { type commandPayload interface { validate() error hasRun() bool - bridgeFlags() []internalcobra.Flag - bridgeArguments() []internalcobra.Argument + bridgeFlags() []commandmeta.Flag + bridgeArguments() []commandmeta.Argument prepare([][]string, []string, bool) (func(*execution) Outcome, error) } @@ -135,11 +135,11 @@ func (c typedCommand[I]) hasRun() bool { return c.spec.Run != nil || c.privateRun != nil } -func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag { - flags := make([]internalcobra.Flag, len(c.spec.Flags)) +func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag { + flags := make([]commandmeta.Flag, len(c.spec.Flags)) for index, flag := range c.spec.Flags { binding := flag.binding - flags[index] = internalcobra.Flag{ + flags[index] = commandmeta.Flag{ Name: binding.meta.Name, Short: binding.meta.Short, Usage: binding.meta.Usage, @@ -152,11 +152,11 @@ func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag { return flags } -func (c typedCommand[I]) bridgeArguments() []internalcobra.Argument { - arguments := make([]internalcobra.Argument, len(c.spec.Arguments)) +func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument { + arguments := make([]commandmeta.Argument, len(c.spec.Arguments)) for index, argument := range c.spec.Arguments { binding := argument.binding - arguments[index] = internalcobra.Argument{ + arguments[index] = commandmeta.Argument{ Name: binding.meta.Name, Usage: binding.meta.Usage, Min: binding.min, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 635f6a1..af096be 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. Stages 1 through 6 are complete; subsequent work follows the ordered roadmap. +Status: implementation in progress. Stages 1 through 7 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. @@ -357,10 +357,16 @@ help/usage presentation. 7. Preserve local flags after the complete command path, interspersed operands, `--`, short help, malformed-input behavior, and public error isolation. -**Completion evidence:** run the now-complete core and adapter suites. Exercise -registration followed by help, callback-driven metadata refresh without replay, -runnable parents, repeated execution with fresh values, cancellation, and public -outcome delivery. No concurrency or snapshot-isolation guarantee is added. +**Completion evidence — complete:** neutral command, binding, parse, and failure +records now live in `internal/commandmeta`; Cobra owns only its mutable session +and adapter logic. One cached snapshot projects sorted metadata and keys to its +runner table. Parsing returns a snapshot-local key, and execution selects from +that same snapshot without a second path lookup. Each invocation creates one +session reused by parse/help/usage; presentation refreshes after registration +without reinterpreting the original key or replaying callbacks. Core and adapter +tests pass, including registration/help refresh, runner-key pairing, runnable +parents, fresh input, cancellation, and outcome delivery. No concurrency or +snapshot-isolation guarantee is added. ## Stage 8: Implement configuration contracts and native ConfigKit adapters diff --git a/execute.go b/execute.go index 8e606e3..f1c0d86 100644 --- a/execute.go +++ b/execute.go @@ -7,6 +7,7 @@ import ( "io" internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" ) // Execute parses and dispatches one invocation. args excludes the executable @@ -19,21 +20,22 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco } streams = normalizeStreams(streams) current := newExecution(a, ctx, streams) + routedSnapshot := current.snapshot parsed := current.parse(args) if parsed.Failure != nil { return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path) } - command, ok := a.commandAt(parsed.Path) - if !ok || command.record == nil || command.record.payload == nil { + payload, ok := routedSnapshot.runners[parsed.Key] + if !ok || payload == nil { return current.present(current.renderHelp(parsed.Path), parsed.Path) } - if parsed.HelpRequested || !command.record.payload.hasRun() { - if _, err := command.record.payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil { + if parsed.HelpRequested || !payload.hasRun() { + if _, err := payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil { return current.present(Usage(err), parsed.Path) } return current.present(current.renderHelp(parsed.Path), parsed.Path) } - run, err := command.record.payload.prepare(parsed.FlagValues, parsed.Operands, true) + run, err := payload.prepare(parsed.FlagValues, parsed.Operands, true) if err != nil { return current.present(Usage(err), parsed.Path) } @@ -44,8 +46,8 @@ type execution struct { app *App ctx context.Context streams Streams - options internalcobra.ParseOptions - metadata *internalcobra.Command + options commandmeta.ParseOptions + snapshot *routingSnapshot session *internalcobra.Session } @@ -70,18 +72,18 @@ func newExecution(app *App, ctx context.Context, streams Streams) *execution { app: app, ctx: ctx, streams: streams, - options: internalcobra.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err}, + options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err}, } if app != nil { - current.metadata = app.bridgeTree() - current.session = internalcobra.Prepare(current.metadata, current.options) + current.snapshot = app.routeSnapshot() + current.session = internalcobra.Prepare(current.snapshot.metadata, current.options) } return current } -func (current *execution) parse(args []string) internalcobra.Result { +func (current *execution) parse(args []string) commandmeta.Result { if current.app == nil { - return internalcobra.Result{Failure: &internalcobra.Failure{Message: "cannot parse commands on a nil app"}} + return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}} } return current.session.Parse(args) } @@ -90,10 +92,10 @@ func (current *execution) presentationSession() *internalcobra.Session { if current.app == nil { return nil } - metadata := current.app.bridgeTree() - if current.session == nil || current.session.Metadata() != metadata { - current.metadata = metadata - current.session = internalcobra.Prepare(metadata, current.options) + snapshot := current.app.routeSnapshot() + if current.session == nil || current.snapshot != snapshot { + current.snapshot = snapshot + current.session = internalcobra.Prepare(snapshot.metadata, current.options) } return current.session } @@ -139,34 +141,6 @@ func normalizeStreams(streams Streams) Streams { return streams } -func (a *App) commandAt(path []string) (Command, bool) { - if a == nil { - return Command{}, false - } - if len(path) == 0 { - return Command{}, true - } - command, ok := a.commands[path[0]] - if !ok { - return Command{}, false - } - current := command.command - for _, name := range path[1:] { - found := false - for _, child := range current.record.children { - if child.record != nil && child.record.name == name { - current = child - found = true - break - } - } - if !found { - return Command{}, false - } - } - return current, true -} - func (a *App) present(outcome Outcome, path []string, streams Streams) Outcome { return a.presentWithUsageLine(outcome, path, streams, a.usageLine) } @@ -222,7 +196,7 @@ func (a *App) presentWithUsageLine(outcome Outcome, path []string, streams Strea func (a *App) usageLine(path []string) string { if a != nil { - if usage, failure := internalcobra.UsageLine(a.bridgeTree(), path); failure == nil { + if usage, failure := internalcobra.UsageLine(a.routeSnapshot().metadata, path); failure == nil { return "Usage: " + usage } } diff --git a/internal/cobra/parse.go b/internal/cobra/parse.go index 71b0526..3ff9b6f 100644 --- a/internal/cobra/parse.go +++ b/internal/cobra/parse.go @@ -10,72 +10,21 @@ import ( "strconv" "strings" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" + spfcobra "github.com/spf13/cobra" "github.com/spf13/pflag" ) -// Command describes one node in a per-parse command tree. -type Command struct { - Name string - Summary string - Description string - Runnable bool - Flags []Flag - Arguments []Argument - Children []*Command -} - -// Argument describes one positional input for usage rendering. -type Argument struct { - Name string - Usage string - Min int - Max int -} - -// Flag describes one local option without exposing a pflag value. -type Flag struct { - Name string - Short string - Usage string - Default string - Boolean bool - Repeated bool - Required bool -} - -// Failure is a backend-owned parse failure translated to neutral message data. -type Failure struct { - Message string -} - -// Result contains the selected command and raw input collected by pflag. -type Result struct { - Path []string - HelpRequested bool - FlagValues [][]string - Operands []string - Failure *Failure -} - -// ParseOptions makes the parser's context and streams explicit. Nil writers -// are replaced with io.Discard. -type ParseOptions struct { - Context context.Context - In io.Reader - Out io.Writer - Err io.Writer -} - // Session owns one mutable backend tree for a single command invocation. type Session struct { - metadata *Command + metadata *commandmeta.Command root *spfcobra.Command - declarations map[*spfcobra.Command]*Command + declarations map[*spfcobra.Command]*commandmeta.Command } // Prepare constructs a fresh backend tree for one invocation. -func Prepare(root *Command, options ParseOptions) *Session { +func Prepare(root *commandmeta.Command, options commandmeta.ParseOptions) *Session { session := &Session{} if root == nil { return session @@ -87,7 +36,7 @@ func Prepare(root *Command, options ParseOptions) *Session { } // Metadata returns the neutral tree used to prepare this session. -func (session *Session) Metadata() *Command { +func (session *Session) Metadata() *commandmeta.Command { if session == nil { return nil } @@ -95,8 +44,8 @@ func (session *Session) Metadata() *Command { } // Parse routes leading command words and parses flags on this session's tree. -func (session *Session) Parse(argv []string) Result { - result := Result{} +func (session *Session) Parse(argv []string) commandmeta.Result { + result := commandmeta.Result{} if session == nil || session.root == nil { return failure(result, "command tree is nil") } @@ -112,6 +61,7 @@ func (session *Session) Parse(argv []string) Result { if selectedNode == nil { return failure(result, "selected command is not part of the declared tree") } + result.Key = selectedNode.Key var pathOK bool result.Path, pathOK = pathFor(selected, session.declarations) if !pathOK { @@ -149,18 +99,18 @@ func (session *Session) Parse(argv []string) Result { } // RenderHelp renders registered metadata for an exact path on this session. -func (session *Session) RenderHelp(path []string) (string, *Failure) { +func (session *Session) RenderHelp(path []string) (string, *commandmeta.Failure) { if session == nil || session.root == nil { - return "", &Failure{Message: "command tree is nil"} + return "", &commandmeta.Failure{Message: "command tree is nil"} } selected, remaining, err := session.root.Find(path) if err != nil { - return "", &Failure{Message: err.Error()} + return "", &commandmeta.Failure{Message: fmt.Sprintf("unknown help path %q", strings.Join(path, " ")), Usage: true} } declaration := session.declarations[selected] selectedPath, pathOK := pathFor(selected, session.declarations) if declaration == nil || !pathOK || len(remaining) != 0 || !equalPath(path, selectedPath) { - return "", &Failure{Message: "help path is not registered"} + return "", &commandmeta.Failure{Message: fmt.Sprintf("unknown help path %q", strings.Join(path, " ")), Usage: true} } buffer := new(bytes.Buffer) previousOut, previousErr := selected.OutOrStdout(), selected.ErrOrStderr() @@ -171,21 +121,21 @@ func (session *Session) RenderHelp(path []string) (string, *Failure) { selected.SetErr(previousErr) }() if err := selected.Usage(); err != nil { - return "", &Failure{Message: err.Error()} + return "", &commandmeta.Failure{Message: err.Error()} } return buffer.String(), nil } // UsageLine returns Cobra's usage line for an exact path on this session. -func (session *Session) UsageLine(path []string) (string, *Failure) { +func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure) { if session == nil || session.root == nil { - return "", &Failure{Message: "command tree is nil"} + return "", &commandmeta.Failure{Message: "command tree is nil"} } selected, remaining, err := session.root.Find(path) declaration := session.declarations[selected] selectedPath, pathOK := pathFor(selected, session.declarations) if err != nil || declaration == nil || !pathOK || len(remaining) != 0 || !equalPath(path, selectedPath) { - return "", &Failure{Message: "usage path is not registered"} + return "", &commandmeta.Failure{Message: "usage path is not registered"} } line := selected.UseLine() if selected.HasSubCommands() { @@ -195,21 +145,21 @@ func (session *Session) UsageLine(path []string) (string, *Failure) { } // RenderHelp renders a fresh command tree for one help request. -func RenderHelp(root *Command, path []string) (string, *Failure) { - return Prepare(root, ParseOptions{}).RenderHelp(path) +func RenderHelp(root *commandmeta.Command, path []string) (string, *commandmeta.Failure) { + return Prepare(root, commandmeta.ParseOptions{}).RenderHelp(path) } // UsageLine resolves a path on a fresh command tree. -func UsageLine(root *Command, path []string) (string, *Failure) { - return Prepare(root, ParseOptions{}).UsageLine(path) +func UsageLine(root *commandmeta.Command, path []string) (string, *commandmeta.Failure) { + return Prepare(root, commandmeta.ParseOptions{}).UsageLine(path) } // Parse constructs a fresh command tree and parses one argv. -func Parse(root *Command, argv []string, options ParseOptions) Result { +func Parse(root *commandmeta.Command, argv []string, options commandmeta.ParseOptions) commandmeta.Result { return Prepare(root, options).Parse(argv) } -func normalizeOptions(options ParseOptions) ParseOptions { +func normalizeOptions(options commandmeta.ParseOptions) commandmeta.ParseOptions { if options.Context == nil { options.Context = context.Background() } @@ -225,10 +175,10 @@ func normalizeOptions(options ParseOptions) ParseOptions { return options } -func buildTree(root *Command, options ParseOptions) (*spfcobra.Command, map[*spfcobra.Command]*Command) { - nodes := make(map[*spfcobra.Command]*Command) - var build func(*Command) *spfcobra.Command - build = func(declaration *Command) *spfcobra.Command { +func buildTree(root *commandmeta.Command, options commandmeta.ParseOptions) (*spfcobra.Command, map[*spfcobra.Command]*commandmeta.Command) { + nodes := make(map[*spfcobra.Command]*commandmeta.Command) + var build func(*commandmeta.Command) *spfcobra.Command + build = func(declaration *commandmeta.Command) *spfcobra.Command { backend := &spfcobra.Command{ Use: commandUse(declaration), Short: declaration.Summary, @@ -290,7 +240,7 @@ Flags: {{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}} ` -func commandUse(command *Command) string { +func commandUse(command *commandmeta.Command) string { var parts []string for _, argument := range command.Arguments { name := argument.Name @@ -312,7 +262,7 @@ func commandUse(command *Command) string { return command.Name + " " + strings.Join(parts, " ") } -func helpDescription(command *Command) string { +func helpDescription(command *commandmeta.Command) string { var sections []string if description := strings.TrimSpace(command.Description); description != "" { sections = append(sections, description) @@ -335,7 +285,7 @@ func helpDescription(command *Command) string { return strings.Join(sections, "\n\n") } -func flagUsage(flag Flag) string { +func flagUsage(flag commandmeta.Flag) string { var details []string if flag.Required { details = append(details, "required") @@ -374,7 +324,7 @@ func splitRoutingPrefix(argv []string) ([]string, []string) { return append([]string(nil), argv...), nil } -func pathFor(selected *spfcobra.Command, declarations map[*spfcobra.Command]*Command) ([]string, bool) { +func pathFor(selected *spfcobra.Command, declarations map[*spfcobra.Command]*commandmeta.Command) ([]string, bool) { var reverse []string for current := selected; current != nil; current = current.Parent() { declaration := declarations[current] @@ -391,8 +341,8 @@ func pathFor(selected *spfcobra.Command, declarations map[*spfcobra.Command]*Com return reverse, true } -func failure(result Result, message string) Result { - result.Failure = &Failure{Message: message} +func failure(result commandmeta.Result, message string) commandmeta.Result { + result.Failure = &commandmeta.Failure{Message: message} return result } diff --git a/internal/cobra/parse_test.go b/internal/cobra/parse_test.go index ffb6c28..152bab7 100644 --- a/internal/cobra/parse_test.go +++ b/internal/cobra/parse_test.go @@ -9,9 +9,17 @@ import ( "strings" "testing" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" spfcobra "github.com/spf13/cobra" ) +type Command = commandmeta.Command +type Argument = commandmeta.Argument +type Flag = commandmeta.Flag +type Failure = commandmeta.Failure +type Result = commandmeta.Result +type ParseOptions = commandmeta.ParseOptions + func TestParseRoutesBeforeOptionsAndKeepsFlagsLocal(t *testing.T) { tree := &Command{Name: "app", Children: []*Command{ {Name: "config", Children: []*Command{ diff --git a/internal/commandmeta/metadata.go b/internal/commandmeta/metadata.go new file mode 100644 index 0000000..864e801 --- /dev/null +++ b/internal/commandmeta/metadata.go @@ -0,0 +1,66 @@ +// Package commandmeta owns the neutral metadata exchanged by CommandKit and +// its command-line backend. +package commandmeta + +import ( + "context" + "io" +) + +// Key identifies one command inside one routing snapshot. +type Key uint64 + +// Command describes one node in a neutral command tree. +type Command struct { + Key Key + Name string + Summary string + Description string + Runnable bool + Flags []Flag + Arguments []Argument + Children []*Command +} + +// Argument describes one positional input for usage rendering. +type Argument struct { + Name string + Usage string + Min int + Max int +} + +// Flag describes one local option without exposing a backend value. +type Flag struct { + Name string + Short string + Usage string + Default string + Boolean bool + Repeated bool + Required bool +} + +// Failure is a neutral parsing or presentation failure. +type Failure struct { + Message string + Usage bool +} + +// Result contains the selected command and raw input collected by the adapter. +type Result struct { + Key Key + Path []string + HelpRequested bool + FlagValues [][]string + Operands []string + Failure *Failure +} + +// ParseOptions makes parser context and streams explicit. +type ParseOptions struct { + Context context.Context + In io.Reader + Out io.Writer + Err io.Writer +} diff --git a/parser.go b/parser.go index 7a246fd..1b76b88 100644 --- a/parser.go +++ b/parser.go @@ -4,46 +4,52 @@ import ( "sort" internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" ) -func (a *App) parse(args []string, options internalcobra.ParseOptions) internalcobra.Result { +func (a *App) parse(args []string, options commandmeta.ParseOptions) commandmeta.Result { if a == nil { - return internalcobra.Result{Failure: &internalcobra.Failure{Message: "cannot parse commands on a nil app"}} + return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}} } - return internalcobra.Parse(a.bridgeTree(), args, options) + return internalcobra.Parse(a.routeSnapshot().metadata, args, options) } -func (a *App) bridgeTree() *internalcobra.Command { +func (a *App) routeSnapshot() *routingSnapshot { if a == nil { return nil } - if a.commandTree == nil { - a.commandTree = a.buildBridgeTree() + if a.snapshot == nil { + a.snapshot = a.buildRoutingSnapshot() } - return a.commandTree + return a.snapshot } -func (a *App) buildBridgeTree() *internalcobra.Command { - root := &internalcobra.Command{Name: a.identity.Name} +func (a *App) buildRoutingSnapshot() *routingSnapshot { + root := &commandmeta.Command{Name: a.identity.Name} + snapshot := &routingSnapshot{metadata: root, runners: make(map[commandmeta.Key]commandPayload)} names := make([]string, 0, len(a.commands)) for name := range a.commands { names = append(names, name) } sort.Strings(names) + var key commandmeta.Key for _, name := range names { - root.Children = append(root.Children, bridgeCommand(a.commands[name].command)) + root.Children = append(root.Children, bridgeCommand(a.commands[name].command, snapshot, &key)) } - return root + return snapshot } -func bridgeCommand(command Command) *internalcobra.Command { +func bridgeCommand(command Command, snapshot *routingSnapshot, next *commandmeta.Key) *commandmeta.Command { record := command.record - declaration := &internalcobra.Command{ + *next = *next + 1 + declaration := &commandmeta.Command{ + Key: *next, Name: record.name, Summary: record.summary, Description: record.description, } if record.payload != nil { + snapshot.runners[declaration.Key] = record.payload declaration.Runnable = record.payload.hasRun() declaration.Flags = record.payload.bridgeFlags() declaration.Arguments = record.payload.bridgeArguments() @@ -54,7 +60,7 @@ func bridgeCommand(command Command) *internalcobra.Command { if child.record == nil || child.record.absent { continue } - declaration.Children = append(declaration.Children, bridgeCommand(child)) + declaration.Children = append(declaration.Children, bridgeCommand(child, snapshot, next)) } return declaration } diff --git a/parser_test.go b/parser_test.go index 397102e..c2cf159 100644 --- a/parser_test.go +++ b/parser_test.go @@ -9,6 +9,7 @@ import ( "testing" internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" ) func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) { @@ -33,7 +34,7 @@ func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) { t.Fatal(err) } - options := internalcobra.ParseOptions{Context: context.Background()} + options := commandmeta.ParseOptions{Context: context.Background()} result := app.parse([]string{"config", "show", "one", "--scope", "app"}, options) if result.Failure != nil { t.Fatalf("parse failed: %s", result.Failure.Message) @@ -47,6 +48,32 @@ func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) { } } +func TestRoutingSnapshotPairsParsedKeyWithItsRunner(t *testing.T) { + command := NewCommand(CommandSpec[struct{}]{Name: "run", Run: func(Invocation, struct{}) Outcome { return Success() }}) + app, err := New(Identity{Name: "example"}, command) + if err != nil { + t.Fatal(err) + } + snapshot := app.routeSnapshot() + session := internalcobra.Prepare(snapshot.metadata, commandmeta.ParseOptions{Context: context.Background()}) + parsed := session.Parse([]string{"run"}) + if parsed.Failure != nil { + t.Fatalf("parse failed: %s", parsed.Failure.Message) + } + if parsed.Key == 0 || snapshot.runners[parsed.Key] == nil || !snapshot.runners[parsed.Key].hasRun() { + t.Fatalf("parsed key %d has no matching runner in its snapshot", parsed.Key) + } + if app.routeSnapshot() != snapshot { + t.Fatal("unchanged registry rebuilt its routing snapshot") + } + if err := app.Register(NewCommand(CommandSpec[struct{}]{Name: "late"})); err != nil { + t.Fatal(err) + } + if app.routeSnapshot() == snapshot { + t.Fatal("successful registration kept stale routing metadata") + } +} + func TestExecutionRefreshesHelpMetadataAfterRegistration(t *testing.T) { first := NewCommand(CommandSpec[struct{}]{ Name: "first", diff --git a/registry.go b/registry.go index b8fbb77..3e01fd4 100644 --- a/registry.go +++ b/registry.go @@ -39,6 +39,6 @@ func (a *App) installBatch(commands ...Command) error { for name, node := range pending { a.commands[name] = node } - a.commandTree = nil + a.snapshot = nil return nil } diff --git a/registry_test.go b/registry_test.go index 0875de3..797c53e 100644 --- a/registry_test.go +++ b/registry_test.go @@ -106,7 +106,7 @@ func TestRunnableCommandCapturesChildrenAndLeavesOriginalUnchanged(t *testing.T) if err != nil { t.Fatalf("runnable parent with children rejected: %v", err) } - if _, ok := app.commandAt([]string{"parent", "child"}); !ok { + if len(app.commands["parent"].command.record.children) != 1 { t.Fatal("child declaration was not installed") } } @@ -122,7 +122,7 @@ func TestGroupCapturesChildrenAndSkipsNestedAbsence(t *testing.T) { if err != nil { t.Fatal(err) } - if _, ok := app.commandAt([]string{"parent", "visible"}); !ok { + if len(app.commands["parent"].command.record.children) != 2 { t.Fatal("captured child missing") } } @@ -160,11 +160,11 @@ func TestNoOpOnlyRegistrationPreservesRoutingSnapshot(t *testing.T) { if err != nil { t.Fatal(err) } - before := app.bridgeTree() + before := app.routeSnapshot() if err := app.Register(NoCommand()); err != nil { t.Fatal(err) } - if after := app.bridgeTree(); after != before { + if after := app.routeSnapshot(); after != before { t.Fatal("no-op-only registration invalidated derived metadata") } } @@ -179,10 +179,10 @@ func TestCapturedDeclarationCanBeInstalledIntoMultipleApps(t *testing.T) { if err != nil { t.Fatal(err) } - if _, ok := first.commandAt([]string{"tools", "run"}); !ok { + if len(first.commands["tools"].command.record.children) != 1 { t.Fatal("first app lost reused declaration") } - if _, ok := second.commandAt([]string{"tools", "run"}); !ok { + if len(second.commands["tools"].command.record.children) != 1 { t.Fatal("second app lost reused declaration") } }