cli: shell completion via cobra + dynamic resource name lookups
Re-enable cobra's default `completion` subcommand (`banger completion bash|zsh|fish|powershell`). Plus live resource-name suggestions that hit the running daemon via the same RPC the real commands use: vm start/stop/restart/delete/kill/set → completeVMNames (variadic) vm ssh/show/logs/stats/ports/... → completeVMNameOnlyAtPos0 vm session list/start → completeVMNameOnlyAtPos0 vm session show/logs/stop/kill/attach/send → completeSessionNames (vm + session) image show/delete/promote → completeImageNameOnlyAtPos0 kernel show/rm → completeKernelNameOnlyAtPos0 vm run/create --image, image pull/register --kernel-ref → flag-value completion Design notes in internal/cli/completion.go: completers never auto-start the daemon (ping-check, bail with NoFileComp on miss), so tab-completion stays a zero-cost probe. Variadic completers exclude already-entered args to avoid duplicate suggestions. README: install recipes for bash / zsh / fish.
This commit is contained in:
parent
346eaba673
commit
e3eaa0c797
4 changed files with 556 additions and 76 deletions
230
internal/cli/completion_test.go
Normal file
230
internal/cli/completion_test.go
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"banger/internal/api"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// stubCompletionSeams installs test doubles for the daemon ping + lister
|
||||
// seams and restores the originals on cleanup. Tests opt into the
|
||||
// sub-functions they actually need.
|
||||
func stubCompletionSeams(
|
||||
t *testing.T,
|
||||
pingErr error,
|
||||
names map[string][]string,
|
||||
listErr error,
|
||||
sessions map[string][]string,
|
||||
sessionErr error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
origPing := daemonPingFunc
|
||||
origLister := completionListerFunc
|
||||
origSessionLister := completionSessionListerFunc
|
||||
t.Cleanup(func() {
|
||||
daemonPingFunc = origPing
|
||||
completionListerFunc = origLister
|
||||
completionSessionListerFunc = origSessionLister
|
||||
})
|
||||
|
||||
daemonPingFunc = func(ctx context.Context, socketPath string) (api.PingResult, error) {
|
||||
if pingErr != nil {
|
||||
return api.PingResult{}, pingErr
|
||||
}
|
||||
return api.PingResult{}, nil
|
||||
}
|
||||
completionListerFunc = func(ctx context.Context, socketPath, method string) ([]string, error) {
|
||||
if listErr != nil {
|
||||
return nil, listErr
|
||||
}
|
||||
return names[method], nil
|
||||
}
|
||||
completionSessionListerFunc = func(ctx context.Context, socketPath, vmIDOrName string) ([]string, error) {
|
||||
if sessionErr != nil {
|
||||
return nil, sessionErr
|
||||
}
|
||||
return sessions[vmIDOrName], nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPrefix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
candidates []string
|
||||
exclude []string
|
||||
prefix string
|
||||
want []string
|
||||
}{
|
||||
{"no filter", []string{"a", "b"}, nil, "", []string{"a", "b"}},
|
||||
{"prefix match", []string{"apple", "banana", "apricot"}, nil, "ap", []string{"apple", "apricot"}},
|
||||
{"exclude already entered", []string{"a", "b", "c"}, []string{"b"}, "", []string{"a", "c"}},
|
||||
{"prefix + exclude", []string{"alpha", "avocado", "banana"}, []string{"alpha"}, "a", []string{"avocado"}},
|
||||
{"exact case sensitive", []string{"VM", "vm"}, nil, "v", []string{"vm"}},
|
||||
{"empty candidates", nil, nil, "any", nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := filterPrefix(tc.candidates, tc.exclude, tc.prefix)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
// Allow nil == empty
|
||||
if len(got) == 0 && len(tc.want) == 0 {
|
||||
return
|
||||
}
|
||||
t.Errorf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testCmdWithCtx() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.SetContext(context.Background())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestCompleteVMNamesHappyPath(t *testing.T) {
|
||||
stubCompletionSeams(t, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil, nil, nil)
|
||||
|
||||
got, directive := completeVMNames(testCmdWithCtx(), nil, "")
|
||||
if directive != cobra.ShellCompDirectiveNoFileComp {
|
||||
t.Errorf("directive = %d, want NoFileComp", directive)
|
||||
}
|
||||
if !reflect.DeepEqual(got, []string{"alpha", "beta", "gamma"}) {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteVMNamesDaemonDown(t *testing.T) {
|
||||
stubCompletionSeams(t, errors.New("connection refused"), nil, nil, nil, nil)
|
||||
|
||||
got, directive := completeVMNames(testCmdWithCtx(), nil, "")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("daemon-down should return no suggestions, got %v", got)
|
||||
}
|
||||
if directive != cobra.ShellCompDirectiveNoFileComp {
|
||||
t.Errorf("directive = %d, want NoFileComp", directive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteVMNamesRPCError(t *testing.T) {
|
||||
stubCompletionSeams(t, nil, nil, errors.New("rpc failed"), nil, nil)
|
||||
|
||||
got, _ := completeVMNames(testCmdWithCtx(), nil, "")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("rpc error should return no suggestions, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteVMNamesExcludesAlreadyEntered(t *testing.T) {
|
||||
stubCompletionSeams(t, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil, nil, nil)
|
||||
|
||||
got, _ := completeVMNames(testCmdWithCtx(), []string{"alpha"}, "")
|
||||
want := []string{"beta", "gamma"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteVMNamesPrefixFilter(t *testing.T) {
|
||||
stubCompletionSeams(t, nil, map[string][]string{"vm.list": {"alpha", "beta", "alphabet"}}, nil, nil, nil)
|
||||
|
||||
got, _ := completeVMNames(testCmdWithCtx(), nil, "alp")
|
||||
want := []string{"alpha", "alphabet"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteVMNameOnlyAtPos0(t *testing.T) {
|
||||
stubCompletionSeams(t, nil, map[string][]string{"vm.list": {"alpha"}}, nil, nil, nil)
|
||||
|
||||
atPos0, _ := completeVMNameOnlyAtPos0(testCmdWithCtx(), nil, "")
|
||||
if len(atPos0) != 1 || atPos0[0] != "alpha" {
|
||||
t.Errorf("pos 0: got %v", atPos0)
|
||||
}
|
||||
|
||||
atPos1, _ := completeVMNameOnlyAtPos0(testCmdWithCtx(), []string{"alpha"}, "")
|
||||
if len(atPos1) != 0 {
|
||||
t.Errorf("pos 1+ should be silent, got %v", atPos1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteImageNames(t *testing.T) {
|
||||
stubCompletionSeams(t, nil, map[string][]string{"image.list": {"debian-bookworm", "alpine"}}, nil, nil, nil)
|
||||
|
||||
got, _ := completeImageNames(testCmdWithCtx(), nil, "")
|
||||
if !reflect.DeepEqual(got, []string{"debian-bookworm", "alpine"}) {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteKernelNames(t *testing.T) {
|
||||
stubCompletionSeams(t, nil, map[string][]string{"kernel.list": {"generic-6.12"}}, nil, nil, nil)
|
||||
|
||||
got, _ := completeKernelNames(testCmdWithCtx(), nil, "")
|
||||
if len(got) != 1 || got[0] != "generic-6.12" {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteImageNameOnlyAtPos0SilentAfterFirst(t *testing.T) {
|
||||
stubCompletionSeams(t, nil, map[string][]string{"image.list": {"alpine"}}, nil, nil, nil)
|
||||
|
||||
after, _ := completeImageNameOnlyAtPos0(testCmdWithCtx(), []string{"alpine"}, "")
|
||||
if len(after) != 0 {
|
||||
t.Errorf("expected silence at pos 1+, got %v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSessionNames(t *testing.T) {
|
||||
stubCompletionSeams(
|
||||
t,
|
||||
nil,
|
||||
map[string][]string{"vm.list": {"devbox"}},
|
||||
nil,
|
||||
map[string][]string{"devbox": {"planner", "worker"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Position 0 → VMs.
|
||||
vms, _ := completeSessionNames(testCmdWithCtx(), nil, "")
|
||||
if len(vms) != 1 || vms[0] != "devbox" {
|
||||
t.Errorf("pos 0: got %v", vms)
|
||||
}
|
||||
|
||||
// Position 1 → sessions scoped to args[0].
|
||||
sessions, _ := completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "")
|
||||
if !reflect.DeepEqual(sessions, []string{"planner", "worker"}) {
|
||||
t.Errorf("pos 1: got %v", sessions)
|
||||
}
|
||||
|
||||
// Position 1 with prefix filter.
|
||||
filtered, _ := completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "wor")
|
||||
if len(filtered) != 1 || filtered[0] != "worker" {
|
||||
t.Errorf("pos 1 prefix: got %v", filtered)
|
||||
}
|
||||
|
||||
// Position 2+ silent.
|
||||
past, _ := completeSessionNames(testCmdWithCtx(), []string{"devbox", "planner"}, "")
|
||||
if len(past) != 0 {
|
||||
t.Errorf("pos 2+: got %v", past)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSessionNamesDaemonDown(t *testing.T) {
|
||||
stubCompletionSeams(t, errors.New("down"), nil, nil, nil, nil)
|
||||
|
||||
got, directive := completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no suggestions when daemon down, got %v", got)
|
||||
}
|
||||
if directive != cobra.ShellCompDirectiveNoFileComp {
|
||||
t.Errorf("directive = %d, want NoFileComp", directive)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue