d63ffd6a3a
Reusable, generics-based CLI extracted from vpp-evpn's cmd/evpnc: a declarative command tree (Node[C]) from which dispatch, '?'-help and TAB-completion are derived, an interactive Shell[C], dynamic slot resolvers (context-dependent via captured args), text-or-JSON output (Emit), and color helpers (Paint/Label/KV). Builds on Linux and OpenBSD (readline termios override). Includes a self-contained example and a design proposal under docs/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
206 lines
7.2 KiB
Go
206 lines
7.2 KiB
Go
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fakeClient stands in for an app's gRPC client. The fixture tree's Dynamic
|
|
// funcs read from it, proving the client is threaded through unchanged and that
|
|
// Dynamic sees the args captured by earlier slot nodes.
|
|
type fakeClient struct {
|
|
instances []string
|
|
evpns map[string][]string // per-instance EVPN names
|
|
}
|
|
|
|
func dynInstances(_ context.Context, c fakeClient, _ []string) []string {
|
|
return c.instances
|
|
}
|
|
|
|
// dynEvpns demonstrates context-dependent lookup: it lists the EVPNs of the
|
|
// instance captured as args[0] earlier on the path.
|
|
func dynEvpns(_ context.Context, c fakeClient, args []string) []string {
|
|
if len(args) == 0 {
|
|
return nil
|
|
}
|
|
return c.evpns[args[0]]
|
|
}
|
|
|
|
func dynWatchOpts(_ context.Context, _ fakeClient, _ []string) []string {
|
|
return []string{"num", "log", "crud"}
|
|
}
|
|
|
|
func runNoop(context.Context, fakeClient, []string) error { return nil }
|
|
|
|
func runQuit(context.Context, fakeClient, []string) error { return ErrQuit }
|
|
|
|
// buildFixture mirrors the real apps' tree shape: singular keyword nodes that
|
|
// double as "list" and "show one", a context-dependent slot, a circular
|
|
// watch-options slot, and a quit leaf.
|
|
func buildFixture() *Node[fakeClient] {
|
|
showInstanceEvpnName := &Node[fakeClient]{Word: "<evpn>", Help: "show one EVPN", Dynamic: dynEvpns, Run: runNoop}
|
|
showInstanceEvpn := &Node[fakeClient]{Word: "evpn", Help: "list the member's EVPNs", Run: runNoop, Children: []*Node[fakeClient]{showInstanceEvpnName}}
|
|
showInstanceName := &Node[fakeClient]{Word: "<id>", Help: "show one member", Dynamic: dynInstances, Run: runNoop, Children: []*Node[fakeClient]{showInstanceEvpn}}
|
|
show := &Node[fakeClient]{Word: "show", Help: "show state", Children: []*Node[fakeClient]{
|
|
{Word: "instance", Help: "list fleet members (add <id> for one)", Run: runNoop, Children: []*Node[fakeClient]{showInstanceName}},
|
|
}}
|
|
|
|
// Circular watch-options slot: every option captured as an arg and may be
|
|
// followed by another option.
|
|
watchOpt := &Node[fakeClient]{Word: "<opts>", Dynamic: dynWatchOpts, Run: runNoop}
|
|
watchOpt.Children = []*Node[fakeClient]{watchOpt}
|
|
watch := &Node[fakeClient]{Word: "watch", Help: "stream events", Children: []*Node[fakeClient]{
|
|
{Word: "events", Help: "watch events", Run: runNoop, Children: []*Node[fakeClient]{watchOpt}},
|
|
}}
|
|
|
|
return &Node[fakeClient]{Children: []*Node[fakeClient]{
|
|
show,
|
|
watch,
|
|
{Word: "quit", Help: "exit", Run: runQuit},
|
|
}}
|
|
}
|
|
|
|
func TestWalk(t *testing.T) {
|
|
root := buildFixture()
|
|
cases := []struct {
|
|
toks []string
|
|
runnable bool
|
|
help string
|
|
args []string
|
|
remaining int
|
|
}{
|
|
{[]string{"show", "instance"}, true, "list fleet members (add <id> for one)", nil, 0},
|
|
{[]string{"show", "instance", "host1"}, true, "show one member", []string{"host1"}, 0},
|
|
{[]string{"show", "instance", "host1", "evpn"}, true, "list the member's EVPNs", []string{"host1"}, 0},
|
|
{[]string{"show", "instance", "host1", "evpn", "blue"}, true, "show one EVPN", []string{"host1", "blue"}, 0},
|
|
// prefix abbreviation: singular, unique keywords resolve from a prefix.
|
|
{[]string{"sh", "inst"}, true, "list fleet members (add <id> for one)", nil, 0},
|
|
// circular watch options are captured as args.
|
|
{[]string{"watch", "events", "num", "5", "crud"}, true, "", []string{"num", "5", "crud"}, 0},
|
|
// unknown trailing token is reported as remaining.
|
|
{[]string{"show", "bogus"}, false, "", nil, 1},
|
|
// "show" itself is not runnable and consumes everything.
|
|
{[]string{"show"}, false, "", nil, 0},
|
|
}
|
|
for _, tc := range cases {
|
|
node, args, remaining := Walk(root, tc.toks)
|
|
if len(remaining) != tc.remaining {
|
|
t.Errorf("Walk(%v) remaining=%v, want %d", tc.toks, remaining, tc.remaining)
|
|
continue
|
|
}
|
|
if tc.remaining > 0 {
|
|
continue
|
|
}
|
|
if gotRunnable := node.Run != nil; gotRunnable != tc.runnable {
|
|
t.Errorf("Walk(%v) runnable=%v, want %v", tc.toks, gotRunnable, tc.runnable)
|
|
continue
|
|
}
|
|
if tc.runnable && node.Help != tc.help {
|
|
t.Errorf("Walk(%v) help=%q, want %q", tc.toks, node.Help, tc.help)
|
|
}
|
|
if strings.Join(args, ",") != strings.Join(tc.args, ",") {
|
|
t.Errorf("Walk(%v) args=%v, want %v", tc.toks, args, tc.args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExpandPathsCoversCommands(t *testing.T) {
|
|
root := buildFixture()
|
|
var joined string
|
|
for _, l := range ExpandPaths(root, "") {
|
|
joined += l.Path + "\n"
|
|
}
|
|
for _, want := range []string{
|
|
"show instance",
|
|
"show instance <id> evpn <evpn>",
|
|
"watch events",
|
|
"quit",
|
|
} {
|
|
if !strings.Contains(joined, want) {
|
|
t.Errorf("ExpandPaths missing %q\n%s", want, joined)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCandidates(t *testing.T) {
|
|
root := buildFixture()
|
|
client := fakeClient{
|
|
instances: []string{"host1", "host2"},
|
|
evpns: map[string][]string{"host1": {"blue", "red"}},
|
|
}
|
|
ctx := context.Background()
|
|
|
|
words := func(ns []*Node[fakeClient]) []string {
|
|
out := make([]string, 0, len(ns))
|
|
for _, n := range ns {
|
|
out = append(out, n.Word)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
eq := func(got, want []string) bool {
|
|
if len(got) != len(want) {
|
|
return false
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Fixed-child completion filtered by partial.
|
|
if got := words(Candidates(ctx, client, root, []string{"show"}, "inst")); !eq(got, []string{"instance"}) {
|
|
t.Errorf(`Candidates(show, "inst") = %v, want [instance]`, got)
|
|
}
|
|
// Dynamic slot values offered for an empty partial.
|
|
if got := words(Candidates(ctx, client, root, []string{"show", "instance"}, "")); !eq(got, []string{"host1", "host2"}) {
|
|
t.Errorf(`Candidates(show instance, "") = %v, want [host1 host2]`, got)
|
|
}
|
|
// Dynamic slot values filtered by partial.
|
|
if got := words(Candidates(ctx, client, root, []string{"show", "instance"}, "host1")); !eq(got, []string{"host1"}) {
|
|
t.Errorf(`Candidates(show instance, "host1") = %v, want [host1]`, got)
|
|
}
|
|
// Context-dependent slot: <evpn> lists the EVPNs of host1 captured earlier.
|
|
if got := words(Candidates(ctx, client, root, []string{"show", "instance", "host1", "evpn"}, "")); !eq(got, []string{"blue", "red"}) {
|
|
t.Errorf(`Candidates(... host1 evpn, "") = %v, want [blue red]`, got)
|
|
}
|
|
// A broken left context offers nothing.
|
|
if got := Candidates(ctx, client, root, []string{"show", "bogus"}, ""); got != nil {
|
|
t.Errorf("Candidates(show bogus) = %v, want nil", got)
|
|
}
|
|
}
|
|
|
|
func TestDispatchRunsCommand(t *testing.T) {
|
|
root := buildFixture()
|
|
var gotArgs []string
|
|
root.Children = append(root.Children, &Node[fakeClient]{
|
|
Word: "echo",
|
|
Children: []*Node[fakeClient]{{
|
|
Word: "<x>",
|
|
Dynamic: func(context.Context, fakeClient, []string) []string { return nil },
|
|
Run: func(_ context.Context, _ fakeClient, a []string) error {
|
|
gotArgs = a
|
|
return nil
|
|
},
|
|
}},
|
|
})
|
|
if err := Dispatch(context.Background(), root, fakeClient{}, []string{"echo", "hi"}); err != nil {
|
|
t.Fatalf("Dispatch echo: %v", err)
|
|
}
|
|
if len(gotArgs) != 1 || gotArgs[0] != "hi" {
|
|
t.Errorf("Run got args %v, want [hi]", gotArgs)
|
|
}
|
|
|
|
// Unknown command surfaces as an error.
|
|
if err := Dispatch(context.Background(), root, fakeClient{}, []string{"nope"}); err == nil {
|
|
t.Error("Dispatch(nope) = nil, want unknown-command error")
|
|
}
|
|
}
|