Route executions through neutral snapshots

This commit is contained in:
2026-10-11 01:25:45 +00:00
parent 24e8cd8629
commit 7e5f84bf16
12 changed files with 217 additions and 191 deletions

13
app.go
View File

@@ -3,14 +3,19 @@ package commandkit
import ( import (
"fmt" "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. // App owns one command registry and its application identity.
type App struct { type App struct {
identity Identity identity Identity
commands map[string]*commandNode commands map[string]*commandNode
commandTree *internalcobra.Command snapshot *routingSnapshot
}
type routingSnapshot struct {
metadata *commandmeta.Command
runners map[commandmeta.Key]commandPayload
} }
type commandNode struct { type commandNode struct {

View File

@@ -6,9 +6,9 @@ import (
"fmt" "fmt"
"io" "io"
"runtime/debug" "runtime/debug"
"strings"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
yaml "go.yaml.in/yaml/v4" 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 { 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) return a.renderHelpOutcomeWithSession(path, streams, session)
} }
func (a *App) renderHelpTargets(targets []string, streams Streams) Outcome { 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) return a.renderHelpTargetsWithSession(targets, streams, session)
} }
func (a *App) renderHelpTargetsWithSession(targets []string, streams Streams, session *internalcobra.Session) Outcome { func (a *App) renderHelpTargetsWithSession(targets []string, streams Streams, session *internalcobra.Session) Outcome {
target, err := a.helpCommandTarget(targets) return a.renderHelpOutcomeWithSession(targets, streams, session)
if err != nil {
return Usage(err)
}
return a.renderHelpOutcomeWithSession(target, streams, session)
} }
func (a *App) renderHelpOutcomeWithSession(path []string, streams Streams, session *internalcobra.Session) Outcome { 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) content, failure := session.RenderHelp(path)
if failure != nil { if failure != nil {
if failure.Usage {
return Usage(errors.New(failure.Message))
}
return Failure(errors.New(failure.Message)) return Failure(errors.New(failure.Message))
} }
if err := writeReport(streams.Out, []byte(content)); err != nil { if err := writeReport(streams.Out, []byte(content)); err != nil {
@@ -201,13 +195,3 @@ func commandKitBuildIdentity(info *debug.BuildInfo) Identity {
} }
return 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
}

View File

@@ -6,7 +6,7 @@ import (
"strings" "strings"
"unicode" "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, // Command is an opaque, reusable declaration captured by NewCommand, Group,
@@ -117,8 +117,8 @@ type CommandSpec[I any] struct {
type commandPayload interface { type commandPayload interface {
validate() error validate() error
hasRun() bool hasRun() bool
bridgeFlags() []internalcobra.Flag bridgeFlags() []commandmeta.Flag
bridgeArguments() []internalcobra.Argument bridgeArguments() []commandmeta.Argument
prepare([][]string, []string, bool) (func(*execution) Outcome, error) 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 return c.spec.Run != nil || c.privateRun != nil
} }
func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag { func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag {
flags := make([]internalcobra.Flag, len(c.spec.Flags)) flags := make([]commandmeta.Flag, len(c.spec.Flags))
for index, flag := range c.spec.Flags { for index, flag := range c.spec.Flags {
binding := flag.binding binding := flag.binding
flags[index] = internalcobra.Flag{ flags[index] = commandmeta.Flag{
Name: binding.meta.Name, Name: binding.meta.Name,
Short: binding.meta.Short, Short: binding.meta.Short,
Usage: binding.meta.Usage, Usage: binding.meta.Usage,
@@ -152,11 +152,11 @@ func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag {
return flags return flags
} }
func (c typedCommand[I]) bridgeArguments() []internalcobra.Argument { func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument {
arguments := make([]internalcobra.Argument, len(c.spec.Arguments)) arguments := make([]commandmeta.Argument, len(c.spec.Arguments))
for index, argument := range c.spec.Arguments { for index, argument := range c.spec.Arguments {
binding := argument.binding binding := argument.binding
arguments[index] = internalcobra.Argument{ arguments[index] = commandmeta.Argument{
Name: binding.meta.Name, Name: binding.meta.Name,
Usage: binding.meta.Usage, Usage: binding.meta.Usage,
Min: binding.min, Min: binding.min,

View File

@@ -1,6 +1,6 @@
# Implement the optional integration modules and captured registration API # 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 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 assignment. This document authorizes no publication or changes to sibling
repositories. repositories.
@@ -357,10 +357,16 @@ help/usage presentation.
7. Preserve local flags after the complete command path, interspersed operands, 7. Preserve local flags after the complete command path, interspersed operands,
`--`, short help, malformed-input behavior, and public error isolation. `--`, short help, malformed-input behavior, and public error isolation.
**Completion evidence:** run the now-complete core and adapter suites. Exercise **Completion evidence — complete:** neutral command, binding, parse, and failure
registration followed by help, callback-driven metadata refresh without replay, records now live in `internal/commandmeta`; Cobra owns only its mutable session
runnable parents, repeated execution with fresh values, cancellation, and public and adapter logic. One cached snapshot projects sorted metadata and keys to its
outcome delivery. No concurrency or snapshot-isolation guarantee is added. 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 ## Stage 8: Implement configuration contracts and native ConfigKit adapters

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" 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 // 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) streams = normalizeStreams(streams)
current := newExecution(a, ctx, streams) current := newExecution(a, ctx, streams)
routedSnapshot := current.snapshot
parsed := current.parse(args) parsed := current.parse(args)
if parsed.Failure != nil { if parsed.Failure != nil {
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path) return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path)
} }
command, ok := a.commandAt(parsed.Path) payload, ok := routedSnapshot.runners[parsed.Key]
if !ok || command.record == nil || command.record.payload == nil { if !ok || payload == nil {
return current.present(current.renderHelp(parsed.Path), parsed.Path) return current.present(current.renderHelp(parsed.Path), parsed.Path)
} }
if parsed.HelpRequested || !command.record.payload.hasRun() { if parsed.HelpRequested || !payload.hasRun() {
if _, err := command.record.payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil { if _, err := payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
return current.present(Usage(err), parsed.Path) return current.present(Usage(err), parsed.Path)
} }
return current.present(current.renderHelp(parsed.Path), 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 { if err != nil {
return current.present(Usage(err), parsed.Path) return current.present(Usage(err), parsed.Path)
} }
@@ -44,8 +46,8 @@ type execution struct {
app *App app *App
ctx context.Context ctx context.Context
streams Streams streams Streams
options internalcobra.ParseOptions options commandmeta.ParseOptions
metadata *internalcobra.Command snapshot *routingSnapshot
session *internalcobra.Session session *internalcobra.Session
} }
@@ -70,18 +72,18 @@ func newExecution(app *App, ctx context.Context, streams Streams) *execution {
app: app, app: app,
ctx: ctx, ctx: ctx,
streams: streams, 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 { if app != nil {
current.metadata = app.bridgeTree() current.snapshot = app.routeSnapshot()
current.session = internalcobra.Prepare(current.metadata, current.options) current.session = internalcobra.Prepare(current.snapshot.metadata, current.options)
} }
return current return current
} }
func (current *execution) parse(args []string) internalcobra.Result { func (current *execution) parse(args []string) commandmeta.Result {
if current.app == nil { 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) return current.session.Parse(args)
} }
@@ -90,10 +92,10 @@ func (current *execution) presentationSession() *internalcobra.Session {
if current.app == nil { if current.app == nil {
return nil return nil
} }
metadata := current.app.bridgeTree() snapshot := current.app.routeSnapshot()
if current.session == nil || current.session.Metadata() != metadata { if current.session == nil || current.snapshot != snapshot {
current.metadata = metadata current.snapshot = snapshot
current.session = internalcobra.Prepare(metadata, current.options) current.session = internalcobra.Prepare(snapshot.metadata, current.options)
} }
return current.session return current.session
} }
@@ -139,34 +141,6 @@ func normalizeStreams(streams Streams) Streams {
return 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 { func (a *App) present(outcome Outcome, path []string, streams Streams) Outcome {
return a.presentWithUsageLine(outcome, path, streams, a.usageLine) 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 { func (a *App) usageLine(path []string) string {
if a != nil { 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 return "Usage: " + usage
} }
} }

View File

@@ -10,72 +10,21 @@ import (
"strconv" "strconv"
"strings" "strings"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
spfcobra "github.com/spf13/cobra" spfcobra "github.com/spf13/cobra"
"github.com/spf13/pflag" "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. // Session owns one mutable backend tree for a single command invocation.
type Session struct { type Session struct {
metadata *Command metadata *commandmeta.Command
root *spfcobra.Command root *spfcobra.Command
declarations map[*spfcobra.Command]*Command declarations map[*spfcobra.Command]*commandmeta.Command
} }
// Prepare constructs a fresh backend tree for one invocation. // 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{} session := &Session{}
if root == nil { if root == nil {
return session return session
@@ -87,7 +36,7 @@ func Prepare(root *Command, options ParseOptions) *Session {
} }
// Metadata returns the neutral tree used to prepare this 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 { if session == nil {
return 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. // Parse routes leading command words and parses flags on this session's tree.
func (session *Session) Parse(argv []string) Result { func (session *Session) Parse(argv []string) commandmeta.Result {
result := Result{} result := commandmeta.Result{}
if session == nil || session.root == nil { if session == nil || session.root == nil {
return failure(result, "command tree is nil") return failure(result, "command tree is nil")
} }
@@ -112,6 +61,7 @@ func (session *Session) Parse(argv []string) Result {
if selectedNode == nil { if selectedNode == nil {
return failure(result, "selected command is not part of the declared tree") return failure(result, "selected command is not part of the declared tree")
} }
result.Key = selectedNode.Key
var pathOK bool var pathOK bool
result.Path, pathOK = pathFor(selected, session.declarations) result.Path, pathOK = pathFor(selected, session.declarations)
if !pathOK { if !pathOK {
@@ -149,18 +99,18 @@ func (session *Session) Parse(argv []string) Result {
} }
// RenderHelp renders registered metadata for an exact path on this session. // 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 { 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) selected, remaining, err := session.root.Find(path)
if err != nil { 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] declaration := session.declarations[selected]
selectedPath, pathOK := pathFor(selected, session.declarations) selectedPath, pathOK := pathFor(selected, session.declarations)
if declaration == nil || !pathOK || len(remaining) != 0 || !equalPath(path, selectedPath) { 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) buffer := new(bytes.Buffer)
previousOut, previousErr := selected.OutOrStdout(), selected.ErrOrStderr() previousOut, previousErr := selected.OutOrStdout(), selected.ErrOrStderr()
@@ -171,21 +121,21 @@ func (session *Session) RenderHelp(path []string) (string, *Failure) {
selected.SetErr(previousErr) selected.SetErr(previousErr)
}() }()
if err := selected.Usage(); err != nil { if err := selected.Usage(); err != nil {
return "", &Failure{Message: err.Error()} return "", &commandmeta.Failure{Message: err.Error()}
} }
return buffer.String(), nil return buffer.String(), nil
} }
// UsageLine returns Cobra's usage line for an exact path on this session. // 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 { 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) selected, remaining, err := session.root.Find(path)
declaration := session.declarations[selected] declaration := session.declarations[selected]
selectedPath, pathOK := pathFor(selected, session.declarations) selectedPath, pathOK := pathFor(selected, session.declarations)
if err != nil || declaration == nil || !pathOK || len(remaining) != 0 || !equalPath(path, selectedPath) { 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() line := selected.UseLine()
if selected.HasSubCommands() { 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. // RenderHelp renders a fresh command tree for one help request.
func RenderHelp(root *Command, path []string) (string, *Failure) { func RenderHelp(root *commandmeta.Command, path []string) (string, *commandmeta.Failure) {
return Prepare(root, ParseOptions{}).RenderHelp(path) return Prepare(root, commandmeta.ParseOptions{}).RenderHelp(path)
} }
// UsageLine resolves a path on a fresh command tree. // UsageLine resolves a path on a fresh command tree.
func UsageLine(root *Command, path []string) (string, *Failure) { func UsageLine(root *commandmeta.Command, path []string) (string, *commandmeta.Failure) {
return Prepare(root, ParseOptions{}).UsageLine(path) return Prepare(root, commandmeta.ParseOptions{}).UsageLine(path)
} }
// Parse constructs a fresh command tree and parses one argv. // 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) return Prepare(root, options).Parse(argv)
} }
func normalizeOptions(options ParseOptions) ParseOptions { func normalizeOptions(options commandmeta.ParseOptions) commandmeta.ParseOptions {
if options.Context == nil { if options.Context == nil {
options.Context = context.Background() options.Context = context.Background()
} }
@@ -225,10 +175,10 @@ func normalizeOptions(options ParseOptions) ParseOptions {
return options return options
} }
func buildTree(root *Command, options ParseOptions) (*spfcobra.Command, map[*spfcobra.Command]*Command) { func buildTree(root *commandmeta.Command, options commandmeta.ParseOptions) (*spfcobra.Command, map[*spfcobra.Command]*commandmeta.Command) {
nodes := make(map[*spfcobra.Command]*Command) nodes := make(map[*spfcobra.Command]*commandmeta.Command)
var build func(*Command) *spfcobra.Command var build func(*commandmeta.Command) *spfcobra.Command
build = func(declaration *Command) *spfcobra.Command { build = func(declaration *commandmeta.Command) *spfcobra.Command {
backend := &spfcobra.Command{ backend := &spfcobra.Command{
Use: commandUse(declaration), Use: commandUse(declaration),
Short: declaration.Summary, Short: declaration.Summary,
@@ -290,7 +240,7 @@ Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}} {{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}
` `
func commandUse(command *Command) string { func commandUse(command *commandmeta.Command) string {
var parts []string var parts []string
for _, argument := range command.Arguments { for _, argument := range command.Arguments {
name := argument.Name name := argument.Name
@@ -312,7 +262,7 @@ func commandUse(command *Command) string {
return command.Name + " " + strings.Join(parts, " ") return command.Name + " " + strings.Join(parts, " ")
} }
func helpDescription(command *Command) string { func helpDescription(command *commandmeta.Command) string {
var sections []string var sections []string
if description := strings.TrimSpace(command.Description); description != "" { if description := strings.TrimSpace(command.Description); description != "" {
sections = append(sections, description) sections = append(sections, description)
@@ -335,7 +285,7 @@ func helpDescription(command *Command) string {
return strings.Join(sections, "\n\n") return strings.Join(sections, "\n\n")
} }
func flagUsage(flag Flag) string { func flagUsage(flag commandmeta.Flag) string {
var details []string var details []string
if flag.Required { if flag.Required {
details = append(details, "required") details = append(details, "required")
@@ -374,7 +324,7 @@ func splitRoutingPrefix(argv []string) ([]string, []string) {
return append([]string(nil), argv...), nil 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 var reverse []string
for current := selected; current != nil; current = current.Parent() { for current := selected; current != nil; current = current.Parent() {
declaration := declarations[current] declaration := declarations[current]
@@ -391,8 +341,8 @@ func pathFor(selected *spfcobra.Command, declarations map[*spfcobra.Command]*Com
return reverse, true return reverse, true
} }
func failure(result Result, message string) Result { func failure(result commandmeta.Result, message string) commandmeta.Result {
result.Failure = &Failure{Message: message} result.Failure = &commandmeta.Failure{Message: message}
return result return result
} }

View File

@@ -9,9 +9,17 @@ import (
"strings" "strings"
"testing" "testing"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
spfcobra "github.com/spf13/cobra" 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) { func TestParseRoutesBeforeOptionsAndKeepsFlagsLocal(t *testing.T) {
tree := &Command{Name: "app", Children: []*Command{ tree := &Command{Name: "app", Children: []*Command{
{Name: "config", Children: []*Command{ {Name: "config", Children: []*Command{

View File

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

View File

@@ -4,46 +4,52 @@ import (
"sort" "sort"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" 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 { 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 { if a == nil {
return nil return nil
} }
if a.commandTree == nil { if a.snapshot == nil {
a.commandTree = a.buildBridgeTree() a.snapshot = a.buildRoutingSnapshot()
} }
return a.commandTree return a.snapshot
} }
func (a *App) buildBridgeTree() *internalcobra.Command { func (a *App) buildRoutingSnapshot() *routingSnapshot {
root := &internalcobra.Command{Name: a.identity.Name} root := &commandmeta.Command{Name: a.identity.Name}
snapshot := &routingSnapshot{metadata: root, runners: make(map[commandmeta.Key]commandPayload)}
names := make([]string, 0, len(a.commands)) names := make([]string, 0, len(a.commands))
for name := range a.commands { for name := range a.commands {
names = append(names, name) names = append(names, name)
} }
sort.Strings(names) sort.Strings(names)
var key commandmeta.Key
for _, name := range names { 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 record := command.record
declaration := &internalcobra.Command{ *next = *next + 1
declaration := &commandmeta.Command{
Key: *next,
Name: record.name, Name: record.name,
Summary: record.summary, Summary: record.summary,
Description: record.description, Description: record.description,
} }
if record.payload != nil { if record.payload != nil {
snapshot.runners[declaration.Key] = record.payload
declaration.Runnable = record.payload.hasRun() declaration.Runnable = record.payload.hasRun()
declaration.Flags = record.payload.bridgeFlags() declaration.Flags = record.payload.bridgeFlags()
declaration.Arguments = record.payload.bridgeArguments() declaration.Arguments = record.payload.bridgeArguments()
@@ -54,7 +60,7 @@ func bridgeCommand(command Command) *internalcobra.Command {
if child.record == nil || child.record.absent { if child.record == nil || child.record.absent {
continue continue
} }
declaration.Children = append(declaration.Children, bridgeCommand(child)) declaration.Children = append(declaration.Children, bridgeCommand(child, snapshot, next))
} }
return declaration return declaration
} }

View File

@@ -9,6 +9,7 @@ import (
"testing" "testing"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
) )
func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) { func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) {
@@ -33,7 +34,7 @@ func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) {
t.Fatal(err) 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) result := app.parse([]string{"config", "show", "one", "--scope", "app"}, options)
if result.Failure != nil { if result.Failure != nil {
t.Fatalf("parse failed: %s", result.Failure.Message) 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) { func TestExecutionRefreshesHelpMetadataAfterRegistration(t *testing.T) {
first := NewCommand(CommandSpec[struct{}]{ first := NewCommand(CommandSpec[struct{}]{
Name: "first", Name: "first",

View File

@@ -39,6 +39,6 @@ func (a *App) installBatch(commands ...Command) error {
for name, node := range pending { for name, node := range pending {
a.commands[name] = node a.commands[name] = node
} }
a.commandTree = nil a.snapshot = nil
return nil return nil
} }

View File

@@ -106,7 +106,7 @@ func TestRunnableCommandCapturesChildrenAndLeavesOriginalUnchanged(t *testing.T)
if err != nil { if err != nil {
t.Fatalf("runnable parent with children rejected: %v", err) 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") t.Fatal("child declaration was not installed")
} }
} }
@@ -122,7 +122,7 @@ func TestGroupCapturesChildrenAndSkipsNestedAbsence(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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") t.Fatal("captured child missing")
} }
} }
@@ -160,11 +160,11 @@ func TestNoOpOnlyRegistrationPreservesRoutingSnapshot(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
before := app.bridgeTree() before := app.routeSnapshot()
if err := app.Register(NoCommand()); err != nil { if err := app.Register(NoCommand()); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if after := app.bridgeTree(); after != before { if after := app.routeSnapshot(); after != before {
t.Fatal("no-op-only registration invalidated derived metadata") t.Fatal("no-op-only registration invalidated derived metadata")
} }
} }
@@ -179,10 +179,10 @@ func TestCapturedDeclarationCanBeInstalledIntoMultipleApps(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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") 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") t.Fatal("second app lost reused declaration")
} }
} }