3 Commits

Author SHA1 Message Date
Pim van Pelt d35e1f2832 feat(render): default JSON-model renderer + bright-white values (v1.4.0)
Render(v) treats JSON as the model: in -json mode it prints v as JSON;
otherwise it paints it as text — object scalars on one line as key=value
(keys blue, values bright white: a structural key/value distinction, not
semantic color), nested objects indented, arrays one block per element,
field order preserved via an order-keeping JSON decode. EmitJSON(v) is
the JSON-only arm for commands that paint their own text. Operates on
JSON only, so core stays protobuf-free (NFR-4).

Adds White to the palette. The example gains an `inspect` command
demoing Render (text vs -json). design.md FR-4.5/4.6 document the
renderer and the "JSON is always the full record; synopsis-vs-detail is
text-only" principle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:13:42 +02:00
Pim van Pelt 496557858d feat(app): make -json opt-in via App.JSON (v1.3.0)
App now registers -json only when App.JSON is true, so a CLI whose
commands do not use cli.Emit never advertises a flag it cannot honor.
Driven by the first real consumer (evpnc), whose commands print text
directly and are not yet converted to Emit. The example opts in
(JSON: true). Backward-additive: existing App users that want -json set
JSON: true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:18:27 +02:00
Pim van Pelt 9e0a98ed07 feat: Validate + keypress subpackage; RFC-style design.md (v1.2.0)
Validate(root): optional startup/test check for tree authoring faults —
>1 slot child per node, empty word, duplicate sibling words, dead-end
node — traversing circular slots without looping (#3).

keypress subpackage: WaitForKey(ctx, cancel) cancels a context on any
keystroke for watch-style streaming commands, with per-GOOS cbreak
(linux TCGETS/TCSETS, BSD TIOCGETA/TIOCSETA) and a non-tty/unsupported
fallback that just waits on ctx. Lifts the last OpenBSD-specific bit out
of evpnc/maglevc's watch.go (#6).

docs: replace PROPOSAL.md with an RFC-2119 design.md (FR/NFR for the
library). Example now dogfoods Validate (a unit test) and keypress (a
bounded `watch` command).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:11:13 +02:00
16 changed files with 876 additions and 264 deletions
+29
View File
@@ -113,6 +113,35 @@ Both are additive conveniences: every builder method returns a plain
`*cli.Node[C]`, so builder and struct-literal construction interoperate, and you `*cli.Node[C]`, so builder and struct-literal construction interoperate, and you
can still drive the lifecycle yourself with `Shell`/`Dispatch` instead of `App`. can still drive the lifecycle yourself with `Shell`/`Dispatch` instead of `App`.
`cli.Validate(root)` reports common authoring faults (more than one slot child
under a node, an empty word, duplicate sibling words, a dead-end node). It is
optional; the idiomatic use is a one-line unit test so a malformed tree fails the
build:
```go
func TestTreeValid(t *testing.T) {
if err := cli.Validate(buildTree()); err != nil { t.Fatal(err) }
}
```
## Streaming commands
For a `watch`-style command that streams until interrupted, the
[`keypress`](keypress) subpackage stops it on any keystroke:
```go
func runWatch(ctx context.Context, c Client, _ []string) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go keypress.WaitForKey(ctx, cancel) // any key cancels ctx
stream, _ := c.Watch(ctx, req)
for { ev, err := stream.Recv(); /* returns when ctx is cancelled */ }
}
```
When stdin is not a terminal it simply waits on the context, so piped/one-shot
use never blocks on a keypress.
## Output: color and JSON ## Output: color and JSON
A command describes its result **once** and the framework renders it: A command describes its result **once** and the framework renders it:
+11 -3
View File
@@ -55,6 +55,10 @@ type App[C any] struct {
Root *Node[C] Root *Node[C]
// Greeting, if set, is printed after the version banner in interactive mode. // Greeting, if set, is printed after the version banner in interactive mode.
Greeting string Greeting string
// JSON, when true, registers a -json flag that switches Emit to JSON output.
// Leave it false for CLIs whose commands do not (yet) use cli.Emit, so they
// never advertise a flag they cannot honor.
JSON bool
// DefaultServer is the -server default. If both it and ServerEnv are empty, // DefaultServer is the -server default. If both it and ServerEnv are empty,
// no -server flag is registered (local CLI). // no -server flag is registered (local CLI).
@@ -109,7 +113,10 @@ func (a *App[C]) Run(ctx context.Context, argv []string) error {
serverFlag = fs.String("server", defaultServer, usage) serverFlag = fs.String("server", defaultServer, usage)
} }
color := fs.Bool("color", true, "colorize output (default: on in the shell, off one-shot)") color := fs.Bool("color", true, "colorize output (default: on in the shell, off one-shot)")
jsonOut := fs.Bool("json", false, "emit JSON instead of text") var jsonOut *bool
if a.JSON {
jsonOut = fs.Bool("json", false, "emit JSON instead of text")
}
showVersion := fs.Bool("version", false, "print version and exit") showVersion := fs.Bool("version", false, "print version and exit")
if a.RegisterFlags != nil { if a.RegisterFlags != nil {
a.RegisterFlags(fs) a.RegisterFlags(fs)
@@ -125,7 +132,8 @@ func (a *App[C]) Run(ctx context.Context, argv []string) error {
fmt.Println(a.versionLine()) fmt.Println(a.versionLine())
return nil return nil
} }
if *jsonOut { jsonMode := jsonOut != nil && *jsonOut
if jsonMode {
SetFormat(FormatJSON) SetFormat(FormatJSON)
} }
@@ -145,7 +153,7 @@ func (a *App[C]) Run(ctx context.Context, argv []string) error {
if colorExplicit { if colorExplicit {
colorOn = *color colorOn = *color
} }
if *jsonOut { if jsonMode {
colorOn = false colorOn = false
} }
SetColor(colorOn) SetColor(colorOn)
+1
View File
@@ -13,6 +13,7 @@ const (
Blue = "\x1b[94m" // bright blue Blue = "\x1b[94m" // bright blue
Yellow = "\x1b[93m" // bright yellow Yellow = "\x1b[93m" // bright yellow
Cyan = "\x1b[96m" // bright cyan Cyan = "\x1b[96m" // bright cyan
White = "\x1b[97m" // bright white
) )
// colorEnabled is process-global, toggled once at startup via SetColor. It // colorEnabled is process-global, toggled once at startup via SetColor. It
-260
View File
@@ -1,260 +0,0 @@
<!--
SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
SPDX-License-Identifier: Apache-2.0
-->
# Proposal: extract the command-tree CLI into a reusable `golang-cli` package
Status: draft / for discussion. Nothing in `vpp-evpn` or `vpp-maglev` has been
changed. This document describes how to lift the command-tree CLI out of
`cmd/evpnc` (vpp-evpn) and `cmd/client` (vpp-maglev) into a standalone module at
`~/src/golang-cli`, and re-import it in both with **no functional difference**.
## 1. Why
Both projects carry the same hand-rolled CLI: a declarative command tree from
which dispatch, `?`-help, and tab-completion are all derived, driven by
`chzyer/readline`. The two copies have already drifted (see §3), and a third
copy would just cargo-cult the drift forward. The parser, the dynamic-node
mechanism, and the command registration are genuinely generic — only the gRPC
*client type* and the *tree contents* are app-specific.
## 2. The three components (as you framed them)
| Your name | Concrete artifact today | Reusable? |
|---|---|---|
| "the parsing tree" | `Node` struct + `Walk` / `matchFixedChild` / `findSlotChild` / `expandPaths` (`tree.go`) | **Yes, verbatim** (modulo client type) |
| "dynamic node function registration" | `Node.Dynamic func(ctx, client, args) []string`, resolved in `Candidates` and the `?` listener | **Yes** — this is the only real API-shape decision (see §3.1) |
| "command function registration" | `Node.Run func(ctx, client, args) error`, dispatched by `dispatch` | **Yes, verbatim** |
Everything that *consumes* the tree is also generic and moves with it:
`Completer` (readline `AutoCompleter`), `questionListener` (the `?` key handler),
`dispatch`, `showHelpAt`, `unknownCommandError`, the REPL loop (`runShell`),
`splitTokens` / `splitForCompletion`, and the OpenBSD termios shim
(`shell_term_openbsd.go` / `_default.go`).
What does **not** move — it stays in each app:
- `buildTree()` and every `run*` / `dyn*` function (`commands.go`) — the actual
command set.
- `color.go` — the `formatError` gRPC-status unwrap stays in-app (wired via
`Shell.FormatError`). The generic `label`/`paint`/`colorEnabled` half **moved
into the library** as `cli.Label`/`cli.Paint`/`cli.SetColor` (see `color.go`,
`output.go`).
- `watch.go` (gRPC server-stream consumer) — app/proto-specific. *But* its
generic half — "cancel a context on any keypress" — can optionally move (§4.3).
- `main.go` — flag parsing, gRPC dial, color-mode defaults.
## 3. What differs between the two copies today
The copies are ~95% identical. The differences, and how the library reconciles
them so neither app changes behaviour:
### 3.1 `Dynamic` signature — the one real decision
- `vpp-evpn`: `func(context.Context, pb.EvpnrClient, []string) []string` — the
trailing `[]string` is the args captured by earlier slot nodes, so `<evpn>`
can list the EVPNs *of the `<id>` already typed*.
- `vpp-maglev`: `func(context.Context, grpcapi.MaglevClient) []string` — no args.
The evpn signature is a strict superset. **The library adopts the args-bearing
form.** maglev's `dyn*` functions gain an ignored `_ []string` parameter — a
purely mechanical edit, zero behaviour change.
### 3.2 Client type — solved with generics
Both projects are `go 1.25`, so generics are available. The package is generic
over the client type `C`:
```go
type Node[C any] struct {
Word string
Help string
Dynamic func(context.Context, C, []string) []string // non-nil => slot node
Children []*Node[C]
Run func(context.Context, C, []string) error
}
```
`C` is `pb.EvpnrClient` in evpnc and `grpcapi.MaglevClient` in maglevc. Run/Dynamic
funcs receive the concrete client — **no `any`, no type assertions** in app code.
(Alternative considered: a non-generic `any` client with assertions in every
`run*`. Rejected — it pushes boilerplate and runtime panics into the apps. See §7.)
### 3.3 Cosmetic drift the library erases (all no-ops behaviourally)
- `Candidates` arg order: evpnc `(ctx, client, root, tokens, partial)` vs maglev
`(root, tokens, partial, ctx, client)`. Library picks the evpnc order.
- evpnc's `Candidates` passes captured `args` to `Dynamic`; maglev discards them
(`node, _, remaining`). Library passes them (superset; maglev's funcs ignore).
- `RunShell`: evpnc calls `applyTermFuncs(cfg)` (OpenBSD fix), maglev does not.
Library always calls it — it's a no-op off OpenBSD, so maglev gains the fix
with no change on Linux/macOS.
- `splitTokens` lives in `complete.go` (maglev) vs `complete.go` (evpnc) — same
body; library exports one `SplitTokens`.
- The `?` listener's partial-token branch carries different comments but identical
logic; unified.
## 4. Proposed package shape
Module: `git.ipng.ch/ipng/golang-cli` (matches your git host). Single package
`cli` to start; the optional keypress helper can be a subpackage.
```
~/src/golang-cli/
go.mod module git.ipng.ch/ipng/golang-cli, go 1.25
go.sum
LICENSE Apache-2.0 (matches the SPDX headers already in the files)
README.md
Makefile check (= fixstyle vet lint test), build, cross (linux + openbsd)
tree.go Node[C], Walk, matchFixedChild, findSlotChild, expandPaths, Candidates
tree_test.go table tests over a fixture tree (no client needed; Walk ignores Dynamic)
builder.go For[C] / Builder[C]: Root/Dir/Cmd/SlotDir/Slot (no repeated [C])
complete.go completer[C], questionListener[C], SplitTokens, splitForCompletion
shell.go Shell[C] (config + Run), Dispatch, showHelpAt, unknownCommandError, ErrQuit
app.go App[C]: standard flags + banner + connect + one-shot/shell -> Main()
color.go SetColor, Paint, Label + the ANSI palette (Red/Green/Blue/Yellow/Cyan)
output.go SetFormat, Emit (text-or-JSON), KV, IsJSON
term_openbsd.go applyTermFuncs (//go:build openbsd) <- from shell_term_openbsd.go
term_default.go applyTermFuncs no-op (//go:build !openbsd) <- from shell_term_default.go
example/main.go self-contained, dependency-free demo CLI (uses Builder + App)
keypress/ (optional) WaitForKeypress + cbreak shim, per-GOOS <- §4.3
```
### 4.1 The one new type: `Shell[C]`
The REPL loop today hard-codes the prompt, the banner, the `errQuit` sentinel,
and `formatError`. Those become a small config struct so each app keeps its
exact strings and error rendering:
```go
type Shell[C any] struct {
Root *Node[C]
Client C
Prompt string // "evpn> " / "maglev> "
FormatError func(error) string // default: err.Error(); evpnc/maglev pass their gRPC-desc unwrap
}
func (s *Shell[C]) Run(ctx context.Context) error // the readline REPL loop
func Dispatch[C any](ctx context.Context, root *Node[C], client C, tokens []string) error
var ErrQuit = errors.New("quit") // a quit/exit Run func returns this; Run() stops on it
```
Banner printing stays in `main.go` (it needs `buildinfo.Version()` etc., which
is app-specific) — `main` prints the banner, then calls `shell.Run(ctx)`.
`FormatError` is how each app keeps its gRPC `desc =` unwrap + red coloring
without the library depending on gRPC at all.
### 4.2 What the app's `main.go` looks like after (evpnc)
```go
root := buildTree() // returns *cli.Node[pb.EvpnrClient]
if len(args) == 0 {
fmt.Printf("evpnc %s ...\n", buildinfo.Version(), ...) // banner
return (&cli.Shell[pb.EvpnrClient]{
Root: root, Client: client, Prompt: "evpn> ", FormatError: formatError,
}).Run(ctx)
}
return cli.Dispatch(ctx, root, client, cli.SplitTokens(strings.Join(args, " ")))
```
`buildTree`, `runQuit` (`return cli.ErrQuit`), all `run*`/`dyn*`, `color.go`,
`watch.go` are unchanged except `Node``cli.Node[pb.EvpnrClient]` and the
`dyn*` signature tweak in maglev.
### 4.3 Optional: the keypress helper
`watch.go` in both apps shares "put stdin in cbreak, cancel ctx on any key", with
per-GOOS termios ioctls (`watch_linux.go` / `watch_bsd.go` in evpnc; inline in
maglev). The proto-streaming half is app-specific and stays, but
`WaitForKeypress(ctx, cancel)` + the cbreak shim are reusable. Suggest a
`cli/keypress` subpackage so apps don't recopy the ioctl tables. Low priority —
it's the least-drifted, most-self-contained piece. Can land in a follow-up.
## 5. Migration plan
**Phase 0 — stand up the module (no app changes).**
1. `git init ~/src/golang-cli`; `go mod init git.ipng.ch/ipng/golang-cli`; `go 1.25`.
2. Copy `tree.go`, `complete.go`, `shell.go` pieces, `shell_term_*.go` in.
Make `Node`, `Walk`, `Candidates`, `Completer`, `questionListener`, `Dispatch`,
`Shell` generic over `C`. Add `Shell.FormatError` and `ErrQuit`.
3. Port `tree_test.go` to a self-contained fixture tree (a tiny `Node[*fakeClient]`
with a couple of slot nodes + a circular watch-opts node) so the package tests
`Walk`/`expandPaths`/`Candidates` with no gRPC dependency. `go test ./...` green.
4. Dependencies: `github.com/chzyer/readline`, `golang.org/x/sys/unix` (both
already in each app's `go.sum`). Tag `v0.1.0`.
**Phase 1 — convert vpp-evpn (the args-bearing reference, less work).**
1. `go get git.ipng.ch/ipng/golang-cli@v0.1.0`; during local dev add a
`replace git.ipng.ch/ipng/golang-cli => ../golang-cli` to iterate.
2. Delete `tree.go`, `complete.go`, `shell.go` body, `shell_term_*.go` from
`cmd/evpnc`. Keep `commands.go`, `color.go`, `watch*.go`, `main.go`.
3. Replace `*Node` with `*cli.Node[pb.EvpnrClient]`; `runQuit` returns
`cli.ErrQuit`; `main` builds a `cli.Shell` (§4.2). `Walk`/`expandPaths` refs in
`watch.go`? none — good.
4. Run the existing checkpoint: `make fixstyle test lint vet`. The
`cmd/evpnc/tree_test.go` `TestWalk`/`TestExpandPaths` move to exercising
`buildTree()` through the library's exported `Walk`/`ExpandPaths` (rename as
needed) — they assert the *app's* tree, so they stay in the app.
**Phase 2 — convert vpp-maglev.**
1. Same wiring. Plus the mechanical edits from §3.1: add `_ []string` to every
`dyn*`; `Candidates`/`Dynamic` call sites now come from the library.
2. maglev's `main.go` color-default logic is equivalent to evpnc's; unchanged.
3. `make` checkpoint green.
**Phase 3 — drop the replace directives, tag a real version, pin both apps.**
## 6. Risks / things to verify during the port
- **Generics + method values.** `Node[C]` is fine; the only subtlety is that
`Completer[C]` and `questionListener[C]` must carry the type param through to
their `readline` interface methods. `readline.AutoCompleter` / `Listener` are
non-generic interfaces, but a concrete `*Completer[pb.EvpnrClient]` satisfies
them — verified shape, no reflection needed.
- **The `?` echo trick** (`questionListener.OnChange` writing the prompt+line to
`rl.Stderr()` before help) depends on readline's wrapWriter behaviour. It's
copied verbatim; no change.
- **OpenBSD.** evpnc has the termios shim and a `make pkg-openbsd`; the library
must keep the `//go:build openbsd` split so maglev gets it without pulling
OpenBSD code into its Linux builds. Verify a `GOOS=openbsd go build` of the
library.
- **No hidden client coupling.** `Walk`/`expandPaths` never touch the client
(the existing `TestWalk` comment confirms it) — so they could even be
non-generic with `Dynamic any`. Keeping them generic is simpler and uniform.
## 7. Open decisions for you
1. **Generics vs `any` client.** Recommended: generics (§3.2) — type-safe,
zero boilerplate in apps. The cost is `[]*cli.Node[pb.EvpnrClient]` verbosity
in `commands.go`; a per-app `type node = cli.Node[pb.EvpnrClient]` alias hides
it entirely.
2. **Module host/name.** `git.ipng.ch/ipng/golang-cli` (consistent) vs a GitHub
mirror if you want it public. Package name `cli` vs something less likely to
collide (`cmdtree`, `cmdline`).
3. **Scope of v0.1.** Minimum = tree + completion + shell. Stretch = the
`keypress` subpackage (§4.3) and maybe a tiny `color` helper if a third client
wants it. Suggest shipping the minimum first; both apps prove the API before
it ossifies.
4. **Whose `Dynamic` shape wins** — already answered (args-bearing, §3.1) unless
you'd rather keep maglev argless and have evpnc pass args by closure capture.
The superset is cleaner.
## 8. Bottom line
"What would it take to make this its own standalone package?" — concretely:
1. A new `go 1.25` module with three files made generic over the client type
(`tree.go`, `complete.go`, `shell.go`) plus the two OpenBSD term files and a
self-contained test — ~1 day including tests.
2. One genuine API choice (args-bearing `Dynamic`, generic `C`) and one new
config type (`Shell[C]` with an injectable `FormatError`). Everything else is
moved verbatim.
3. Mechanical edits in each app: swap `Node` for `cli.Node[ClientT]`, route the
REPL through `cli.Shell`, return `cli.ErrQuit` from quit, and (maglev only)
add an ignored args param to each `dyn*`. No behaviour changes; both `make`
checkpoints stay green.
The tree, the dynamic-node mechanism, and the command/run registration are all
already cleanly separated in these files — the refactor is mostly *relocation +
generics*, not redesign.
+168
View File
@@ -0,0 +1,168 @@
<!--
SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
SPDX-License-Identifier: Apache-2.0
-->
# golang-cli Design Document
| | |
| --- | --- |
| **Status** | Describes shipped behavior as of `v1.4.0` |
| **Author** | Pim van Pelt `<pim@ipng.ch>` |
| **Last updated** | 2026-06-05 |
| **Audience** | Contributors, and authors of CLIs built on this library |
The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are
used as described in [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119),
and are reserved for requirements enforced in code. Plain-language "can"/"will"/
"does" are descriptive, not normative.
## Summary
`golang-cli` builds network-daemon-style CLIs: an interactive shell with
tab-completion and `?`-help, plus a one-shot mode for scripts, both driven by one
declarative command tree. It is the shared core extracted from `evpnc`
(`vpp-evpn`) and `maglevc` (`vpp-maglev`), which had each grown their own copy.
It is generic over a *client* type, and renders command output as colorized text
or JSON from the same code.
## Goals
1. **One tree, one truth.** Dispatch, help, and completion all derive from a
single command tree, so they cannot drift.
2. **Type-safe over any backend.** Command code receives its concrete client, no
runtime type assertions.
3. **Drop-in for `evpnc` and `maglevc`** with no functional regression.
4. **Low ceremony.** A tree and a `main()` in a few lines, not a copied file.
5. **Text or JSON** from the same command code.
6. **Linux and OpenBSD** are both first-class.
## Non-Goals
- Not a flag parser (uses stdlib `flag`) and not a TUI toolkit.
- Does not own transport: it never dials, connects, or speaks a wire protocol.
- Defines no command set, no output format — those are the caller's.
- Holds no state beyond two process-global render switches (color, format).
## Functional Requirements
**FR-1 Command tree and resolution**
- **FR-1.1** The command set MUST be one tree of `Node[C]`; dispatch, `?`-help,
and completion MUST all derive from it, with no second command table.
- **FR-1.2** `Walk` MUST prefer a fixed child over the slot child, matching fixed
by exact word then unique prefix.
- **FR-1.3** A unique-prefix token MUST resolve to that child; an ambiguous
prefix MUST NOT resolve.
- **FR-1.4** A node MAY both run and have children.
- **FR-1.5** A slot node (`Dynamic != nil`) MUST capture any token as an
argument; captured args MUST reach the matched node's `Run` in order.
- **FR-1.6** An unconsumable token MUST be returned to the caller and reported as
an unknown-command error naming the first bad token, not anchored at the root.
- **FR-1.7** At most one slot child per node is reachable; `Validate` SHOULD flag
more than one.
**FR-2 Completion and help**
- **FR-2.1** `Dynamic` MUST receive the args captured by earlier slots, so
candidates can depend on tokens already typed.
- **FR-2.2** Completion MUST offer fixed children and slot values that have the
partial token as a prefix.
- **FR-2.3** If a confirmed token is unknown, completion MUST offer nothing.
- **FR-2.4** Each completion/`?` `Dynamic` call MUST be timeout-bounded
(default 1s).
- **FR-2.5** `?` MUST list the reachable runnable paths with help and live slot
values, without submitting the line.
**FR-3 Shell and one-shot**
- **FR-3.1** No positional args MUST start the REPL; positional args MUST run one
command and exit.
- **FR-3.2** A command MUST be able to stop the REPL by returning `ErrQuit`.
- **FR-3.3** A REPL command error MUST be printed and MUST NOT stop the loop; a
one-shot command error MUST exit non-zero.
- **FR-3.4** `Ctrl-C` MUST abandon the line and continue; `Ctrl-D` MUST exit.
**FR-4 Output**
- **FR-4.1** A command MUST be able to describe one result once and have it
rendered as text or JSON per a process-global format (`Emit`).
- **FR-4.2** In JSON mode the framework MUST marshal the command's machine value;
supplying it is the command's responsibility.
- **FR-4.3** With color off or JSON selected, no ANSI escapes MUST be emitted.
- **FR-4.4** Color MUST default on in the shell, off one-shot; `-color` overrides;
`-json` forces it off.
- **FR-4.5** `Render` MUST treat JSON as the model: in JSON mode it prints the
value as JSON; otherwise it paints it as text — object scalars on one line as
`key=value` (keys blue, values bright white, a purely *structural* distinction),
nested objects indented, arrays one block per element, source field order
preserved. It MUST NOT apply semantic (red/green) color; that is the caller's
job via its own printer (EmitJSON in the JSON arm, a painter otherwise).
- **FR-4.6** JSON MUST always be the full record. The synopsis-vs-detail choice
(a one-line overview list vs an expanded section) is a text-only concern; the
same command emits complete JSON in either case.
**FR-5 `App` entry point**
- **FR-5.1** `App` MUST register `-color` and `-version`; `-server` only when a
server is configured; and `-json` only when the app opts in (its commands use
`Emit`), so a CLI never advertises a flag it cannot honor.
- **FR-5.2** `App` MUST NOT dial anything; the client is built in a caller's
`Connect` callback.
- **FR-5.3** `-version` MUST print and exit without connecting.
- **FR-5.4** The server address MUST resolve from `-server`, else the configured
env var, else the configured default.
**FR-6 Authoring helpers**
- **FR-6.1** `For[C]()`/`Builder` MUST build the tree without repeating `[C]` per
node, returning plain `*Node[C]` so builder and literal construction mix.
- **FR-6.2** `Validate` MUST report: >1 slot child per node, an empty word on a
non-root node, duplicate fixed words among siblings, and a node that neither
runs nor has children; it MUST handle circular slots without looping.
**FR-7 Cancel-on-keypress (`keypress` subpackage)**
- **FR-7.1** A streaming command MUST be able to cancel a context on any keypress.
- **FR-7.2** When stdin is not a terminal, it MUST NOT consume input or cancel
spuriously; it MUST just wait on the context.
- **FR-7.3** It MUST use cbreak mode (single keystroke, echo off, output
post-processing intact) and MUST restore the terminal on return.
## Non-Functional Requirements
- **NFR-1** The library MUST be generic over the client type `C`; no `any`-typed
clients, no reflection on the client.
- **NFR-2** `Builder`, `App`, and `Emit` MUST be optional — a caller MUST be able
to use struct-literal `Node`s with `Shell`/`Dispatch` directly.
- **NFR-3** MUST build for `linux` and `openbsd` (SHOULD also for other BSDs and
macOS). The OpenBSD `readline` termios workaround MUST be transparent and a
no-op elsewhere.
- **NFR-4** Core dependencies MUST be only `chzyer/readline` and `golang.org/x/sys`
— no gRPC, protobuf, or web dependency.
- **NFR-5** Releases MUST be semver tags; the import path is stable across
`v0`/`v1`; a breaking `v2` MUST use the `/v2` suffix.
- **NFR-6** Requires Go 1.25+ (generics with methods on generic types).
- **NFR-7** `make check` (gofmt, vet, golangci-lint, tests) MUST pass before a
commit; new behavior SHOULD ship with a server-free test.
## Architecture
```
argv ──▶ App.Run ──┬─ no args ─▶ Shell.Run ─▶ readline loop ─▶ Dispatch ─▶ Node.Run
│ ▲ ▲
│ Completer ─┘ └─ '?' listener
└─ args ─────────▶ Dispatch ───────────────────────────▶ Node.Run
```
Both modes share everything below `Dispatch`. Completion and `?` read the same
tree the dispatcher walks (FR-1.1) — that is what keeps them from drifting.
## Open Questions
- **Quoted arguments.** Tokenizing is `strings.Fields`, so no argument can
contain a space; a shell-style splitter would be needed for quoted values.
- **Output sink.** `Emit` writes to `os.Stdout`; an injectable `io.Writer` would
make ported commands' output unit-testable. Not needed yet.
- **JSON errors.** One-shot errors print as text on stderr even under `-json`;
`{"error": "..."}` may be worth it once a consumer needs it.
+46 -1
View File
@@ -35,6 +35,7 @@ import (
"strings" "strings"
cli "git.ipng.ch/ipng/golang-cli" cli "git.ipng.ch/ipng/golang-cli"
"git.ipng.ch/ipng/golang-cli/keypress"
) )
// inventory is the "client" C that the tree is generic over. In a real app this // inventory is the "client" C that the tree is generic over. In a real app this
@@ -154,6 +155,47 @@ func runColors(context.Context, inventory, []string) error {
return nil return nil
} }
// runWatch streams a few simulated events, stopping early if a key is pressed.
// In a real CLI this would be a server-side stream; here it is a bounded loop so
// the demo never hangs when stdin is not a terminal. Press any key (in a TTY) to
// stop it before it finishes.
func runWatch(ctx context.Context, _ inventory, _ []string) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go keypress.WaitForKey(ctx, cancel)
for i := 1; i <= 5; i++ {
select {
case <-ctx.Done():
return nil // key pressed
default:
}
if err := cli.Emit(cli.KV("event", fmt.Sprintf("%d", i)), map[string]any{"event": i}); err != nil {
return err
}
}
return nil
}
// runInspect demonstrates the default renderer: it builds one value (the model)
// and hands it to cli.Render, which prints it as JSON under -json or as painted
// text (blue keys, bright-white values, nested objects indented) otherwise — no
// per-command text code.
func runInspect(_ context.Context, inv inventory, _ []string) error {
type host struct {
Name string `json:"name"`
Services []string `json:"services"`
}
f := struct {
Count int `json:"count"`
Hosts []host `json:"hosts"`
}{Count: len(inv.servers)}
for _, n := range inv.names() {
f.Hosts = append(f.Hosts, host{Name: n, Services: inv.servers[n]})
}
return cli.Render(f)
}
func runQuit(context.Context, inventory, []string) error { return cli.ErrQuit } func runQuit(context.Context, inventory, []string) error { return cli.ErrQuit }
// buildTree is the single source of truth for the command set, built with the // buildTree is the single source of truth for the command set, built with the
@@ -168,6 +210,8 @@ func buildTree() *cli.Node[inventory] {
b.Slot("<svc>", "show one service", dynServices, runShowServerService))))), b.Slot("<svc>", "show one service", dynServices, runShowServerService))))),
b.Dir("ping", "ping a server", b.Dir("ping", "ping a server",
b.Slot("<name>", "ping this server", dynServers, runPing)), b.Slot("<name>", "ping this server", dynServers, runPing)),
b.Cmd("watch", "stream a few events (any key stops it)", runWatch),
b.Cmd("inspect", "render the fleet via the default renderer (try -json / -color)", runInspect),
b.Cmd("colors", "show the ANSI color palette", runColors), b.Cmd("colors", "show the ANSI color palette", runColors),
b.Cmd("quit", "exit the shell", runQuit), b.Cmd("quit", "exit the shell", runQuit),
b.Cmd("exit", "exit the shell", runQuit), b.Cmd("exit", "exit the shell", runQuit),
@@ -177,9 +221,10 @@ func buildTree() *cli.Node[inventory] {
func main() { func main() {
(&cli.App[inventory]{ (&cli.App[inventory]{
Name: "example", Name: "example",
Version: "1.1.0", Version: "1.3.0",
Prompt: "inv> ", Prompt: "inv> ",
Root: buildTree(), Root: buildTree(),
JSON: true, // commands use cli.Emit, so advertise -json
Greeting: "golang-cli example — try: show server, ping db1, colors, '?' for help, TAB to complete", Greeting: "golang-cli example — try: show server, ping db1, colors, '?' for help, TAB to complete",
// Local CLI: no -server flag. Connect just hands over the in-memory data, // Local CLI: no -server flag. Connect just hands over the in-memory data,
// proving App is transport-agnostic (it never dials anything itself). // proving App is transport-agnostic (it never dials anything itself).
+20
View File
@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package main
import (
"testing"
cli "git.ipng.ch/ipng/golang-cli"
)
// TestTreeValid keeps the example's command tree free of the authoring faults
// Validate checks for. It doubles as the recommended way to use Validate: from
// a unit test, so a malformed tree fails the build rather than misdispatching
// at runtime.
func TestTreeValid(t *testing.T) {
if err := cli.Validate(buildTree()); err != nil {
t.Fatalf("buildTree() has authoring faults:\n%v", err)
}
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
//go:build openbsd || freebsd || netbsd || dragonfly || darwin
package keypress
import "golang.org/x/sys/unix"
// cbreak puts the terminal into cbreak mode (no canonical input/echo) so a
// single keystroke is available, leaving output post-processing intact. The
// BSDs use the TIOCGETA/TIOCSETA termios ioctls (Linux uses TCGETS/TCSETS). It
// returns the previous termios for restore, or an error if fd is not a tty.
func cbreak(fd int) (*unix.Termios, error) {
old, err := unix.IoctlGetTermios(fd, unix.TIOCGETA)
if err != nil {
return nil, err
}
t := *old
t.Lflag &^= unix.ICANON | unix.ECHO | unix.ECHOE | unix.ECHOK | unix.ECHONL
t.Cc[unix.VMIN] = 1
t.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(fd, unix.TIOCSETA, &t); err != nil {
return nil, err
}
return old, nil
}
// restore reverts the terminal to the settings captured by cbreak.
func restore(fd int, old *unix.Termios) error {
return unix.IoctlSetTermios(fd, unix.TIOCSETAF, old)
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
//go:build linux
package keypress
import "golang.org/x/sys/unix"
// cbreak puts the terminal into cbreak mode (no canonical input/echo) so a
// single keystroke is available, leaving output post-processing intact. Linux
// uses the TCGETS/TCSETS termios ioctls (the BSDs use TIOCGETA/TIOCSETA). It
// returns the previous termios for restore, or an error if fd is not a tty.
func cbreak(fd int) (*unix.Termios, error) {
old, err := unix.IoctlGetTermios(fd, unix.TCGETS)
if err != nil {
return nil, err
}
t := *old
t.Lflag &^= unix.ICANON | unix.ECHO | unix.ECHOE | unix.ECHOK | unix.ECHONL
t.Cc[unix.VMIN] = 1
t.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(fd, unix.TCSETS, &t); err != nil {
return nil, err
}
return old, nil
}
// restore reverts the terminal to the settings captured by cbreak.
func restore(fd int, old *unix.Termios) error {
return unix.IoctlSetTermios(fd, unix.TCSETSF, old)
}
+19
View File
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
//go:build !linux && !openbsd && !freebsd && !netbsd && !dragonfly && !darwin
package keypress
import "errors"
// termiosState is a placeholder so cbreak/restore have a consistent signature on
// platforms without a supported termios path. On those platforms cbreak always
// fails, so WaitForKey degrades to waiting on the context (it never reads stdin).
type termiosState struct{}
func cbreak(int) (*termiosState, error) {
return nil, errors.New("keypress: cbreak is unsupported on this platform")
}
func restore(int, *termiosState) error { return nil }
+52
View File
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
// Package keypress lets a streaming command stop on any keystroke. A
// watch-style command runs WaitForKey in a goroutine with a cancellable
// context; the first key pressed cancels the context, tearing down the stream.
//
// ctx, cancel := context.WithCancel(ctx)
// defer cancel()
// go keypress.WaitForKey(ctx, cancel)
// for { ev, err := stream.Recv(); ... } // returns when ctx is cancelled
//
// When standard input is not a terminal (piped, redirected, backgrounded) there
// is no keystroke to wait for, so WaitForKey just blocks until ctx ends and
// never cancels on its own.
package keypress
import (
"context"
"os"
)
// WaitForKey blocks until a key is pressed on standard input or ctx is done,
// whichever comes first; on a keypress it calls cancel. If stdin is not a
// terminal it waits on ctx only (it neither reads input nor calls cancel). The
// terminal is placed in cbreak mode for the duration and restored on return.
//
// Run it in its own goroutine. If ctx ends first, WaitForKey returns and
// restores the terminal; a read already blocked on stdin is left to be reaped
// when the process exits (there is no portable way to interrupt it).
func WaitForKey(ctx context.Context, cancel context.CancelFunc) {
fd := int(os.Stdin.Fd())
old, err := cbreak(fd)
if err != nil {
<-ctx.Done() // stdin is not a tty: nothing to read, just honor ctx
return
}
defer func() { _ = restore(fd, old) }()
readDone := make(chan struct{})
go func() {
defer close(readDone)
buf := make([]byte, 1)
_, _ = os.Stdin.Read(buf)
}()
select {
case <-readDone:
cancel()
case <-ctx.Done():
}
}
+31
View File
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package keypress
import (
"context"
"testing"
"time"
)
// TestWaitForKeyReturnsOnContextCancel checks WaitForKey unblocks when the
// context ends, both on the non-tty path (cbreak fails → wait on ctx) and the
// tty path (select returns on ctx.Done). Under `go test` stdin is normally not
// a terminal, exercising the former.
func TestWaitForKeyReturnsOnContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // already done
done := make(chan struct{})
go func() {
WaitForKey(ctx, func() {})
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("WaitForKey did not return after the context was cancelled")
}
}
+210
View File
@@ -0,0 +1,210 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"bytes"
"encoding/json"
"fmt"
"os"
"strings"
)
// Render emits v in the current output format, treating JSON as the model. In
// JSON mode it prints v as indented JSON. Otherwise it paints v as text: each
// object's scalar fields render on one line as blue "key=value" pairs (keys
// blue, values bright white, so the data stands out from the labels), nested
// objects indent under a blue "key:" header, and arrays render as one block per
// element.
//
// Pass a json.RawMessage (e.g. from protojson) to control the exact JSON and
// preserve field order; pass a struct or map and encoding/json handles it
// (structs keep field order, maps sort keys).
//
// Render is the default presentation, equivalent to "no printer". A command that
// wants bespoke text — semantic color, tables, custom layout — should instead
// branch on IsJSON(), calling EmitJSON in the JSON arm and its own painter
// otherwise. The library never applies semantic (red/green) color; that is the
// command's choice, because only it knows what a value means.
func Render(v any) error {
raw, err := toRawJSON(v)
if err != nil {
return err
}
if IsJSON() {
return printIndentedJSON(raw)
}
s, err := renderText(raw)
if err != nil {
return err
}
_, _ = fmt.Fprint(os.Stdout, s)
return nil
}
// renderText paints raw JSON as text (blue keys, white values). Split out so it
// can be tested without capturing stdout.
func renderText(raw json.RawMessage) (string, error) {
val, err := parseOrdered(raw)
if err != nil {
return "", err
}
var b strings.Builder
paintValue(&b, val, "")
return b.String(), nil
}
// EmitJSON prints v as indented JSON to stdout, regardless of the output format.
// Use it in the JSON arm of a command that paints its own text.
func EmitJSON(v any) error {
raw, err := toRawJSON(v)
if err != nil {
return err
}
return printIndentedJSON(raw)
}
func toRawJSON(v any) (json.RawMessage, error) {
if rm, ok := v.(json.RawMessage); ok {
return rm, nil
}
b, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("marshal json: %w", err)
}
return b, nil
}
func printIndentedJSON(raw json.RawMessage) error {
var buf bytes.Buffer
if err := json.Indent(&buf, raw, "", " "); err != nil {
_, _ = fmt.Fprintln(os.Stdout, string(raw)) // not indentable JSON — print as-is
return nil
}
_, _ = fmt.Fprintln(os.Stdout, buf.String())
return nil
}
// orderedField is one key/value of a JSON object, preserving source order so the
// painted text follows the order of the (proto) JSON rather than Go map order.
type orderedField struct {
key string
val any // string, json.Number, bool, nil, []any, or []orderedField
}
func parseOrdered(raw json.RawMessage) (any, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
v, err := parseOrderedValue(dec)
if err != nil {
return nil, fmt.Errorf("parse json: %w", err)
}
return v, nil
}
func parseOrderedValue(dec *json.Decoder) (any, error) {
t, err := dec.Token()
if err != nil {
return nil, err
}
if d, ok := t.(json.Delim); ok {
switch d {
case '{':
return parseOrderedObject(dec)
case '[':
return parseOrderedArray(dec)
}
}
return t, nil // string, json.Number, bool, or nil
}
func parseOrderedObject(dec *json.Decoder) ([]orderedField, error) {
var fields []orderedField
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
return nil, err
}
val, err := parseOrderedValue(dec)
if err != nil {
return nil, err
}
fields = append(fields, orderedField{key: keyTok.(string), val: val})
}
_, err := dec.Token() // closing '}'
return fields, err
}
func parseOrderedArray(dec *json.Decoder) ([]any, error) {
var arr []any
for dec.More() {
v, err := parseOrderedValue(dec)
if err != nil {
return nil, err
}
arr = append(arr, v)
}
_, err := dec.Token() // closing ']'
return arr, err
}
func paintValue(b *strings.Builder, v any, indent string) {
switch t := v.(type) {
case []orderedField:
paintObject(b, t, indent)
case []any:
paintArray(b, t, indent)
default:
fmt.Fprintf(b, "%s%s\n", indent, Paint(scalarString(v), White))
}
}
func paintObject(b *strings.Builder, fields []orderedField, indent string) {
var scalars []string
var nested []orderedField
for _, f := range fields {
switch f.val.(type) {
case []orderedField, []any:
nested = append(nested, f)
default:
scalars = append(scalars, Label(f.key)+"="+Paint(scalarString(f.val), White))
}
}
if len(scalars) > 0 {
fmt.Fprintf(b, "%s%s\n", indent, strings.Join(scalars, " "))
}
for _, f := range nested {
fmt.Fprintf(b, "%s%s:\n", indent, Label(f.key))
paintValue(b, f.val, indent+" ")
}
}
func paintArray(b *strings.Builder, arr []any, indent string) {
for _, el := range arr {
switch el.(type) {
case []orderedField, []any:
paintValue(b, el, indent)
default:
fmt.Fprintf(b, "%s%s\n", indent, Paint(scalarString(el), White))
}
}
}
func scalarString(v any) string {
switch x := v.(type) {
case nil:
return "null"
case bool:
if x {
return "true"
}
return "false"
case json.Number:
return x.String()
case string:
return x
default:
return fmt.Sprintf("%v", x)
}
}
+92
View File
@@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"encoding/json"
"strings"
"testing"
)
// TestRenderTextNoColor checks the plain text shape: object scalars on one line
// as key=value, nested objects indented under "key:", arrays as one block per
// element, and source field order preserved (not sorted).
func TestRenderTextNoColor(t *testing.T) {
SetColor(false)
raw := json.RawMessage(`{
"instanceId": "host1",
"connected": true,
"version": "1.2.3",
"labels": {"site": "ams", "rack": "b3"},
"bvis": [
{"evpnId": "blue", "installed": true},
{"evpnId": "red", "installed": false}
]
}`)
got, err := renderText(raw)
if err != nil {
t.Fatalf("renderText: %v", err)
}
want := "instanceId=host1 connected=true version=1.2.3\n" +
"labels:\n" +
" site=ams rack=b3\n" +
"bvis:\n" +
" evpnId=blue installed=true\n" +
" evpnId=red installed=false\n"
if got != want {
t.Errorf("renderText mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
// TestRenderTextColor checks keys are blue and values bright white when color is
// on.
func TestRenderTextColor(t *testing.T) {
SetColor(true)
defer SetColor(false)
got, err := renderText(json.RawMessage(`{"a": "x"}`))
if err != nil {
t.Fatalf("renderText: %v", err)
}
want := Blue + "a" + Reset + "=" + White + "x" + Reset + "\n"
if got != want {
t.Errorf("colored render = %q, want %q", got, want)
}
}
// TestRenderPreservesNumberAndNull checks json.Number passes through verbatim
// (no float reformatting) and null renders as "null".
func TestRenderPreservesNumberAndNull(t *testing.T) {
SetColor(false)
got, err := renderText(json.RawMessage(`{"vni": 10000000, "primary": null}`))
if err != nil {
t.Fatalf("renderText: %v", err)
}
if !strings.Contains(got, "vni=10000000") {
t.Errorf("number not verbatim: %q", got)
}
if !strings.Contains(got, "primary=null") {
t.Errorf("null not rendered: %q", got)
}
}
// TestRenderTextFromStruct checks a Go struct (not RawMessage) is accepted and
// keeps struct field order.
func TestRenderTextFromStruct(t *testing.T) {
SetColor(false)
v := struct {
Name string `json:"name"`
Count int `json:"count"`
}{Name: "web1", Count: 3}
raw, err := toRawJSON(v)
if err != nil {
t.Fatalf("toRawJSON: %v", err)
}
got, err := renderText(raw)
if err != nil {
t.Fatalf("renderText: %v", err)
}
if got != "name=web1 count=3\n" {
t.Errorf("struct render = %q", got)
}
}
+77
View File
@@ -0,0 +1,77 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"errors"
"fmt"
"strings"
)
// Validate reports common authoring faults in a command tree. Walk tolerates
// all of them, so Validate is optional — but calling it once at startup, or
// from a unit test, catches mistakes that otherwise cause silent misdispatch.
// It returns all problems found, joined, or nil if the tree is clean.
//
// It reports:
// - a node with more than one slot child (Dynamic != nil): only the first is
// ever reachable (FR-1.7);
// - a non-root node with an empty Word: it can never be matched or displayed;
// - duplicate fixed (non-slot) words among a node's children: the second is
// shadowed by the first;
// - a node that neither runs nor has children: a dead end that can only print
// "<no completions>".
//
// Circular slots (a slot that is its own descendant, e.g. a watch-options node)
// are traversed once, not looped.
func Validate[C any](root *Node[C]) error {
var errs []error
validateNode(root, "", true, make(map[*Node[C]]bool), &errs)
return errors.Join(errs...)
}
func validateNode[C any](n *Node[C], path string, isRoot bool, seen map[*Node[C]]bool, errs *[]error) {
if seen[n] {
return
}
seen[n] = true
where := path
if where == "" {
where = "<root>"
}
if !isRoot && n.Word == "" {
*errs = append(*errs, fmt.Errorf("%s: node has an empty Word", where))
}
if !isRoot && n.Run == nil && len(n.Children) == 0 {
*errs = append(*errs, fmt.Errorf("%s: node neither runs nor has children (dead end)", where))
}
slots := 0
fixedWords := make(map[string]bool)
for _, c := range n.Children {
if c.Dynamic != nil {
slots++
continue
}
if c.Word != "" {
if fixedWords[c.Word] {
*errs = append(*errs, fmt.Errorf("%s: duplicate child word %q", where, c.Word))
}
fixedWords[c.Word] = true
}
}
if slots > 1 {
*errs = append(*errs, fmt.Errorf("%s: %d slot children, but only the first is reachable", where, slots))
}
for _, c := range n.Children {
cp := c.Word
if path != "" {
cp = path + " " + c.Word
}
validateNode(c, strings.TrimSpace(cp), false, seen, errs)
}
}
+56
View File
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
// SPDX-License-Identifier: Apache-2.0
package cli
import (
"context"
"strings"
"testing"
)
// TestValidateClean checks that a well-formed tree (the builder fixture, which
// includes a circular slot) validates without error.
func TestValidateClean(t *testing.T) {
if err := Validate(buildFixtureB()); err != nil {
t.Errorf("Validate(clean tree) = %v, want nil", err)
}
}
// TestValidateFaults checks each fault class is reported.
func TestValidateFaults(t *testing.T) {
dyn := func(context.Context, fakeClient, []string) []string { return nil }
root := &Node[fakeClient]{Children: []*Node[fakeClient]{
// two slot children under one node
{Word: "twoslots", Help: "", Children: []*Node[fakeClient]{
{Word: "<a>", Dynamic: dyn, Run: runNoop},
{Word: "<b>", Dynamic: dyn, Run: runNoop},
}},
// duplicate fixed words
{Word: "dup", Children: []*Node[fakeClient]{
{Word: "x", Run: runNoop},
{Word: "x", Run: runNoop},
}},
// dead end: neither runs nor has children
{Word: "deadend"},
// empty word
{Word: "", Run: runNoop},
}}
err := Validate(root)
if err == nil {
t.Fatal("Validate(broken tree) = nil, want errors")
}
msg := err.Error()
for _, want := range []string{
"only the first is reachable",
`duplicate child word "x"`,
"dead end",
"empty Word",
} {
if !strings.Contains(msg, want) {
t.Errorf("Validate missing %q in:\n%s", want, msg)
}
}
}