diff --git a/commands_test.go b/commands_test.go index dce5c94..0d79cbb 100644 --- a/commands_test.go +++ b/commands_test.go @@ -121,6 +121,26 @@ func TestExpand(t *testing.T) { } } +func TestToCommandsThreadsArgsToDeepDescendants(t *testing.T) { + descriptors := []*commandDescriptor{ + {Name: "a", Commands: []*commandDescriptor{ + {Name: "b", Commands: []*commandDescriptor{ + {Name: "c", Commands: []*commandDescriptor{ + {Name: "d1"}, + {Name: "d2"}, + }}, + }}, + }}, + } + parent := &executableCommand{path: "/tool", name: "tool"} + + cmds := toCommands(parent, descriptors, nil, &discoverer{maxDepth: -1}) + + c := cmds.Find("a").(*executableCommand).cmds.Find("b").(*executableCommand).cmds.Find("c").(*executableCommand) + assert.Equal(t, []string{"a", "b", "c", "d1"}, c.cmds.Find("d1").(*executableCommand).args) + assert.Equal(t, []string{"a", "b", "c", "d2"}, c.cmds.Find("d2").(*executableCommand).args) +} + func TestExpandWithDepthZeroDoesNotCallSubcommands(t *testing.T) { stub := &stubParent{name: "stub"} diff --git a/contract.go b/contract.go index 38cc783..6acb45f 100644 --- a/contract.go +++ b/contract.go @@ -194,6 +194,11 @@ type commandDescriptor struct { // is not populated; children are represented by Commands above. It is nil for // commands discovered via contracts other than OpenCLI. openCLI *opencli.Command + + // describedBy, when set, indicates that the command can describe its own + // subcommands (e.g. by responding to --help-opencli) even though Commands + // is empty. toCommands uses it to defer discovery to the command itself. + describedBy describeFunc } func readSummaryFromShellScript(cmd *shellScriptCommand) (string, error) { diff --git a/contract_opencli.go b/contract_opencli.go index 8837b9d..ddfc7d6 100644 --- a/contract_opencli.go +++ b/contract_opencli.go @@ -16,6 +16,11 @@ import ( // // Unlike ExecutableContract, this contract does not require any particular // file extension. Any executable file is eligible. +// +// A document may describe its subcommands progressively: when a subcommand's +// entry lists no children but declares the option "--help-opencli", its +// subcommands are discovered on demand by invoking the subcommand with +// --help-opencli (e.g. `tool sub --help-opencli`). type OpenCLIContract struct{} // OpenCLIDescriber is implemented by Commands that can describe themselves @@ -78,7 +83,10 @@ func (c *OpenCLIContract) BuildCommand(path string, info fs.DirEntry, parent Com // describeOpenCLI is a describeFunc that invokes --help-opencli // and parses the OpenCLI JSON output into a commandDescriptor. func describeOpenCLI(cmd *executableCommand) (*commandDescriptor, error) { - out, err := cmd.cache.Fetch(cmd, "help-opencli", func() (string, error) { + // Subcommands that respond to --help-opencli themselves share the + // executable's path, so the cache key must include their arguments. + key := strings.Join(append([]string{"help-opencli"}, cmd.args...), " ") + out, err := cmd.cache.Fetch(cmd, key, func() (string, error) { return helpOpenCLIRaw(cmd) }) if err != nil { @@ -150,6 +158,19 @@ func opencliToDescriptor(cmd opencli.Command) *commandDescriptor { for i, sub := range cmd.Commands { d.Commands[i] = opencliToDescriptor(sub) } + } else if respondsToHelpOpenCLI(cmd) { + d.describedBy = describeOpenCLI } return d } + +// respondsToHelpOpenCLI returns true if the command declares --help-opencli +// among its options, signaling that it can describe its own subcommands. +func respondsToHelpOpenCLI(cmd opencli.Command) bool { + for _, o := range cmd.Options { + if o.Name == "--help-opencli" { + return true + } + } + return false +} diff --git a/contract_opencli_test.go b/contract_opencli_test.go index 0cc7bdd..fcd43bc 100644 --- a/contract_opencli_test.go +++ b/contract_opencli_test.go @@ -3,6 +3,7 @@ package exoskeleton import ( "io/fs" "os" + "os/exec" "path/filepath" "testing" @@ -88,10 +89,11 @@ func TestOpenCLICommandDiscovery(t *testing.T) { // Subcommands are discovered from OpenCLI output cmds, err := cmd.Subcommands() assert.NoError(t, err) - assert.Len(t, cmds, 3) + assert.Len(t, cmds, 4) assert.Equal(t, "build", cmds[0].Name()) assert.Equal(t, "mod", cmds[1].Name()) assert.Equal(t, "hidden-cmd", cmds[2].Name()) + assert.Equal(t, "google-drive", cmds[3].Name()) // Nested subcommands modCmds, err := cmds[1].Subcommands() @@ -147,6 +149,82 @@ func TestOpenCLICommandExposesMetadata(t *testing.T) { assert.Empty(t, node.Commands) } +// buildOpenCLITool builds a Command from the opencli-tool fixture. +func buildOpenCLITool(t *testing.T, d *discoverer) Command { + t.Helper() + path := filepath.Join(fixtures, "opencli-tool") + info, err := os.Lstat(path) + assert.NoError(t, err) + cmd, err := (&OpenCLIContract{}).BuildCommand(path, fs.FileInfoToDirEntry(info), nil, d) + assert.NoError(t, err) + return cmd +} + +func TestOpenCLIProgressiveDiscovery(t *testing.T) { + execs := 0 + countingExecutor := func(c *exec.Cmd) error { execs++; return c.Run() } + cmd := buildOpenCLITool(t, &discoverer{maxDepth: -1, executor: countingExecutor}) + + cmds, err := cmd.Subcommands() + assert.NoError(t, err) + assert.Equal(t, 1, execs) + + // The root document lists no subcommands for google-drive but declares + // that it responds to --help-opencli. Its sigil and summary come from + // the root document without executing anything... + gd := cmds.Find("google-drive") + assert.True(t, HasSubcommands(gd)) + summary, err := gd.Summary() + assert.NoError(t, err) + assert.Equal(t, "Work with Google Drive", summary) + assert.Equal(t, 1, execs) + + // ...and Subcommands() invokes `opencli-tool google-drive --help-opencli`. + gdCmds, err := gd.Subcommands() + assert.NoError(t, err) + assert.Equal(t, 2, execs) + assert.Len(t, gdCmds, 2) + + // Descendants are invoked with the full path of arguments. + docs := gdCmds[0].(*executableCommand) + assert.Equal(t, []string{cmd.Path(), "google-drive", "docs"}, docs.Command().Args) + + // OpenCLICommand() returns google-drive's own description of itself, + // which is richer than its entry in the root document. + node, err := gd.(OpenCLIDescriber).OpenCLICommand() + assert.NoError(t, err) + assert.Equal(t, "Work with files in Google Drive", *node.Description) +} + +func TestOpenCLIProgressiveDiscoveryRespectsMaxDepth(t *testing.T) { + cmd := buildOpenCLITool(t, &discoverer{maxDepth: 1, executor: defaultExecutor}) + + cmds, err := cmd.Subcommands() + assert.NoError(t, err) + + gdCmds, err := cmds.Find("google-drive").Subcommands() + assert.NoError(t, err) + assert.Empty(t, gdCmds) +} + +func TestOpenCLIProgressiveDiscoveryCacheKeysDoNotCollide(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "cache.json") + + subcommandsOfGoogleDrive := func() Commands { + cmd := buildOpenCLITool(t, &discoverer{maxDepth: -1, executor: defaultExecutor, cache: &FileCache{Path: cachePath}}) + cmds, err := cmd.Subcommands() + assert.NoError(t, err) + gdCmds, err := cmds.Find("google-drive").Subcommands() + assert.NoError(t, err) + return gdCmds + } + + // The second call reads google-drive's document from the cache; a key + // collision would return the root document instead. + assert.Len(t, subcommandsOfGoogleDrive(), 2) + assert.Len(t, subcommandsOfGoogleDrive(), 2) +} + func TestOpenCLICommandForNonOpenCLIContract(t *testing.T) { // A command that was not discovered via the OpenCLI contract has no // OpenCLI metadata; its richer fields come from the Command interface. diff --git a/executable_command.go b/executable_command.go index 0e74755..403c3b5 100644 --- a/executable_command.go +++ b/executable_command.go @@ -82,7 +82,7 @@ func (cmd *executableCommand) Complete(_ *Entrypoint, args, env []string) ([]str // The executable is expected to write the summary to standard output and exit // successfully. func (cmd *executableCommand) Summary() (string, error) { - if cmd.discoverer != nil && cmd.cmds == nil { + if cmd.summary == nil && cmd.discoverer != nil && cmd.cmds == nil { if err := cmd.discover(); err != nil { return "", err } @@ -184,7 +184,7 @@ func (cmd *executableCommand) discover() error { cmd.summary = descriptor.Summary cmd.defaultSubcommand = descriptor.DefaultCommand cmd.openCLI = descriptor.openCLI - cmd.cmds = toCommands(cmd, descriptor.Commands, nil, cmd.discoverer) + cmd.cmds = toCommands(cmd, descriptor.Commands, cmd.args, cmd.discoverer) return nil } @@ -195,7 +195,9 @@ func (cmd *executableCommand) discover() error { // The returned Command describes only this node. Its Commands field is not // populated; walk Subcommands() to describe the command tree. func (cmd *executableCommand) OpenCLICommand() (*opencli.Command, error) { - if cmd.openCLI == nil && cmd.discoverer != nil && cmd.cmds == nil { + // Discover before answering: a command that describes its own subcommands + // provides a richer self-description than its entry in its parent's document. + if cmd.discoverer != nil && cmd.cmds == nil { if err := cmd.discover(); err != nil { return nil, err } @@ -210,11 +212,14 @@ func (cmd *executableCommand) OpenCLICommand() (*opencli.Command, error) { func toCommands(parent *executableCommand, descriptors []*commandDescriptor, args []string, d DiscoveryContext) Commands { cmds := Commands{} for _, descriptor := range descriptors { + // Allocate exactly, so that sibling commands never share a backing array. + cmdArgs := append(append(make([]string, 0, len(args)+1), args...), descriptor.Name) + c := &executableCommand{ parent: parent, discoveredIn: parent.discoveredIn, path: parent.path, - args: append(args, descriptor.Name), + args: cmdArgs, name: descriptor.Name, aliases: descriptor.Aliases, summary: descriptor.Summary, @@ -225,9 +230,16 @@ func toCommands(parent *executableCommand, descriptors []*commandDescriptor, arg contract: parent.contract, } - if len(descriptor.Commands) > 0 && d.MaxDepth() != 0 { - c.discoverer = d.Next() - c.cmds = toCommands(c, descriptor.Commands, append(args, c.name), d.Next()) + if d.MaxDepth() != 0 { + if len(descriptor.Commands) > 0 { + c.discoverer = d.Next() + c.cmds = toCommands(c, descriptor.Commands, cmdArgs, d.Next()) + } else if descriptor.describedBy != nil { + // The command can describe its own subcommands: defer + // discovery until Subcommands() is called. + c.discoverer = d.Next() + c.describe = descriptor.describedBy + } } cmds = append(cmds, c) } diff --git a/fixtures/opencli-tool b/fixtures/opencli-tool index 375312e..4313e4a 100755 --- a/fixtures/opencli-tool +++ b/fixtures/opencli-tool @@ -1,6 +1,29 @@ #!/usr/bin/env bash # SUMMARY: An OpenCLI tool +if [[ "$1" == "google-drive" && "$2" == "--help-opencli" ]]; then + cat << EOF +{ + "opencli": "0.1-block.1", + "name": "google-drive", + "info": {"version": "1.0.0"}, + "summary": "Work with Google Drive", + "description": "Work with files in Google Drive", + "commands": [ + { + "name": "docs", + "summary": "Work with Google Docs" + }, + { + "name": "sheets", + "summary": "Work with Google Sheets" + } + ] +} +EOF + exit 0 +fi + if [[ "$1" == "--help-opencli" ]]; then cat << EOF { @@ -39,6 +62,13 @@ if [[ "$1" == "--help-opencli" ]]; then "arguments": [ {"name": "file"} ] + }, + { + "name": "google-drive", + "summary": "Work with Google Drive", + "options": [ + {"name": "--help-opencli", "hidden": true} + ] } ] }