feat: golang-cli v1.0.0 — generic command-tree CLI library
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>
This commit is contained in:
+205
@@ -0,0 +1,205 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user