feat(menu): interactive command launcher package
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// Package menu renders Crucible's interactive launcher: a numbered list of
|
||||
// commands for users who don't want to memorize subcommands. It holds no
|
||||
// builder logic — it returns the chosen dispatcher argument slice for the
|
||||
// caller to run. This is the seed of a future GUI front-end over the same core.
|
||||
package menu
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Item is one selectable command.
|
||||
type Item struct {
|
||||
Label string // e.g. "module build"
|
||||
Desc string // e.g. "build/extract/validate/compare the .mod"
|
||||
Args []string // dispatcher args, e.g. {"module", "build"}
|
||||
}
|
||||
|
||||
// ANSI styling; terminals that don't support it simply ignore the codes.
|
||||
const (
|
||||
cReset = "\033[0m"
|
||||
cBold = "\033[1m"
|
||||
cCyan = "\033[36m"
|
||||
cDim = "\033[2m"
|
||||
)
|
||||
|
||||
// Select prints the numbered menu to out, reads a selection plus optional extra
|
||||
// arguments from in, and returns the full dispatcher argument slice. It returns
|
||||
// (nil, nil) when the user quits or enters an empty choice.
|
||||
func Select(out io.Writer, in io.Reader, items []Item) ([]string, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("no commands available")
|
||||
}
|
||||
fmt.Fprintf(out, "%s%sCrucible%s — pick a command:\n\n", cBold, cCyan, cReset)
|
||||
for i, it := range items {
|
||||
fmt.Fprintf(out, " %s%2d%s %s%-24s%s %s%s%s\n",
|
||||
cCyan, i+1, cReset, cBold, it.Label, cReset, cDim, it.Desc, cReset)
|
||||
}
|
||||
fmt.Fprintf(out, " %sq%s quit\n\nselection: ", cCyan, cReset)
|
||||
|
||||
r := bufio.NewReader(in)
|
||||
line, _ := r.ReadString('\n')
|
||||
switch choice := strings.TrimSpace(line); choice {
|
||||
case "", "q", "Q":
|
||||
return nil, nil
|
||||
default:
|
||||
n, err := strconv.Atoi(choice)
|
||||
if err != nil || n < 1 || n > len(items) {
|
||||
return nil, fmt.Errorf("invalid selection %q", choice)
|
||||
}
|
||||
it := items[n-1]
|
||||
fmt.Fprintf(out, "extra args for %q (optional): ", it.Label)
|
||||
argLine, _ := r.ReadString('\n')
|
||||
extra := strings.Fields(strings.TrimSpace(argLine))
|
||||
return append(append([]string{}, it.Args...), extra...), nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package menu
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var sample = []Item{
|
||||
{Label: "module build", Desc: "build the .mod", Args: []string{"module", "build"}},
|
||||
{Label: "topdata validate-topdata", Desc: "validate topdata", Args: []string{"topdata", "validate-topdata"}},
|
||||
}
|
||||
|
||||
func TestSelectReturnsChosenArgs(t *testing.T) {
|
||||
got, err := Select(io.Discard, strings.NewReader("1\n\n"), sample)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := []string{"module", "build"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectAppendsExtraArgs(t *testing.T) {
|
||||
got, err := Select(io.Discard, strings.NewReader("2\n--dry-run foo\n"), sample)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"topdata", "validate-topdata", "--dry-run", "foo"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectQuitReturnsNil(t *testing.T) {
|
||||
got, err := Select(io.Discard, strings.NewReader("q\n"), sample)
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("quit should return (nil,nil); got (%v,%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInvalidErrors(t *testing.T) {
|
||||
if _, err := Select(io.Discard, strings.NewReader("99\n"), sample); err == nil {
|
||||
t.Fatal("out-of-range selection should error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user