feat: builder + App runner, Makefile (v1.1.0)

Builder (cli.For[C]): Root/Dir/Cmd/SlotDir/Slot construct the tree
without repeating the [C] type parameter at every node; returns plain
*Node[C] so it interoperates with struct-literal construction.

App[C]: collapses the per-binary main.go — standard flags
(-color/-json/-version, -server when configured), mode-aware color
defaults, version banner, client connect, and the one-shot-vs-shell
split — into one Main(). Transport-agnostic via a Connect callback, so
it never assumes gRPC.

Makefile: `make check` = fixstyle vet lint test (the pre-commit gate),
plus build and a linux+openbsd cross target. The example now dogfoods
both Builder and App. Tests cover the builder tree and App's one-shot
dispatch / -version / nil-Connect paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 21:59:33 +02:00
parent d63ffd6a3a
commit e030cd28e9
8 changed files with 538 additions and 64 deletions
+86
View File
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"context"
"strings"
"testing"
)
// buildFixtureB constructs the same tree shape as buildFixture (tree_test.go)
// but via the Builder, so the two are interchangeable. dynInstances, dynEvpns,
// dynWatchOpts, runNoop and runQuit are defined in tree_test.go (same package).
func buildFixtureB() *Node[fakeClient] {
b := For[fakeClient]()
// Circular watch-options slot: built then wired to itself.
watchOpt := b.Slot("<opts>", "", dynWatchOpts, runNoop)
watchOpt.Children = []*Node[fakeClient]{watchOpt}
return b.Root(
b.Dir("show", "show state",
b.Cmd("instance", "list fleet members (add <id> for one)", runNoop,
b.Slot("<id>", "show one member", dynInstances, runNoop,
b.Cmd("evpn", "list the member's EVPNs", runNoop,
b.Slot("<evpn>", "show one EVPN", dynEvpns, runNoop))))),
b.Dir("watch", "stream events",
b.Cmd("events", "watch events", runNoop, watchOpt)),
b.Cmd("quit", "exit", runQuit),
)
}
// TestBuilderMatchesLiteral checks the Builder produces a tree that Walks
// identically to the struct-literal fixture: same runnable nodes, captured
// args, prefix abbreviation, and circular-slot handling.
func TestBuilderMatchesLiteral(t *testing.T) {
root := buildFixtureB()
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", "blue"}, true, "show one EVPN", []string{"host1", "blue"}, 0},
{[]string{"sh", "inst"}, true, "list fleet members (add <id> for one)", nil, 0},
{[]string{"watch", "events", "num", "5", "crud"}, true, "", []string{"num", "5", "crud"}, 0},
{[]string{"show", "bogus"}, false, "", nil, 1},
}
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 got := node.Run != nil; got != tc.runnable {
t.Errorf("Walk(%v) runnable=%v, want %v", tc.toks, got, tc.runnable)
}
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)
}
}
}
// TestBuilderDynamicCandidates checks a Builder-built slot still resolves
// context-dependent candidates through its Dynamic function.
func TestBuilderDynamicCandidates(t *testing.T) {
root := buildFixtureB()
client := fakeClient{
instances: []string{"host1", "host2"},
evpns: map[string][]string{"host1": {"blue", "red"}},
}
got := Candidates(context.Background(), client, root, []string{"show", "instance", "host1", "evpn"}, "")
if len(got) != 2 || got[0].Word != "blue" || got[1].Word != "red" {
t.Errorf("Candidates(... host1 evpn) = %v, want [blue red]", got)
}
}