From 011a41e90abe0f5fcf5a07806eec8270c09c4e41 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 16 Jun 2026 08:29:02 +0200 Subject: [PATCH] feat(menu): interactive command launcher package --- internal/menu/menu.go | 60 ++++++++++++++++++++++++++++++++++++++ internal/menu/menu_test.go | 47 +++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 internal/menu/menu.go create mode 100644 internal/menu/menu_test.go diff --git a/internal/menu/menu.go b/internal/menu/menu.go new file mode 100644 index 0000000..9503d18 --- /dev/null +++ b/internal/menu/menu.go @@ -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 + } +} diff --git a/internal/menu/menu_test.go b/internal/menu/menu_test.go new file mode 100644 index 0000000..3fe284d --- /dev/null +++ b/internal/menu/menu_test.go @@ -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") + } +}