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
+79
View File
@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"context"
"testing"
)
// TestAppOneShotDispatch runs a one-shot command through App.Run and checks the
// positional args reach the command, the captured slot arg is correct, and the
// client from Connect is threaded through.
func TestAppOneShotDispatch(t *testing.T) {
var gotArg string
var gotClient fakeClient
b := For[fakeClient]()
root := b.Root(
b.Dir("ping", "",
b.Slot("<name>", "", func(context.Context, fakeClient, []string) []string { return nil },
func(_ context.Context, c fakeClient, args []string) error {
gotClient = c
gotArg = args[0]
return nil
})),
)
app := &App[fakeClient]{
Name: "t",
Root: root,
Connect: func(context.Context, string) (fakeClient, func(), error) {
return fakeClient{instances: []string{"sentinel"}}, nil, nil
},
}
if err := app.Run(context.Background(), []string{"ping", "host9"}); err != nil {
t.Fatalf("Run: %v", err)
}
if gotArg != "host9" {
t.Errorf("arg = %q, want host9", gotArg)
}
if len(gotClient.instances) != 1 || gotClient.instances[0] != "sentinel" {
t.Errorf("client not threaded from Connect: %+v", gotClient)
}
}
// TestAppVersion checks -version short-circuits before connecting.
func TestAppVersion(t *testing.T) {
connected := false
app := &App[fakeClient]{
Name: "t",
Version: "9.9.9",
Root: For[fakeClient]().Root(),
Connect: func(context.Context, string) (fakeClient, func(), error) {
connected = true
return fakeClient{}, nil, nil
},
}
if err := app.Run(context.Background(), []string{"-version"}); err != nil {
t.Fatalf("Run -version: %v", err)
}
if connected {
t.Error("-version should not connect")
}
}
// TestAppNilConnect checks a local CLI with no Connect uses the zero client.
func TestAppNilConnect(t *testing.T) {
ran := false
b := For[fakeClient]()
app := &App[fakeClient]{
Name: "t",
Root: b.Root(b.Cmd("go", "", func(context.Context, fakeClient, []string) error { ran = true; return nil })),
}
if err := app.Run(context.Background(), []string{"go"}); err != nil {
t.Fatalf("Run: %v", err)
}
if !ran {
t.Error("command did not run with nil Connect")
}
}