feat(tui): add self-documenting help with ASCII diagrams and table helpers

TUI improvements for Christmas launch:
- Add phase transition table and lifecycle diagram to `gt molecule --help`
- Add swarm lifecycle diagram to `gt swarm --help`
- Add mail routing diagram to `gt mail --help`
- Add sling mechanics diagram to `gt sling --help`
- Create Lipgloss table helper (internal/style/table.go)
- Migrate mq_list to use styled tables with color-coded priorities
- Migrate molecule list to use styled tables
- Add fuzzy matching "did you mean" suggestions for polecat not found errors
- Add suggest package with Levenshtein distance implementation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-22 17:52:32 -08:00
parent 469ba5c488
commit df0495be32
9 changed files with 822 additions and 43 deletions

View File

@@ -43,7 +43,38 @@ var mailCmd = &cobra.Command{
Long: `Send and receive messages between agents.
The mail system allows Mayor, polecats, and the Refinery to communicate.
Messages are stored in beads as issues with type=message.`,
Messages are stored in beads as issues with type=message.
MAIL ROUTING:
┌─────────────────────────────────────────────────────┐
│ Town (.beads/) │
│ ┌─────────────────────────────────────────────┐ │
│ │ Mayor Inbox │ │
│ │ └── mayor/ │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ gastown/ (rig mailboxes) │ │
│ │ ├── witness ← gastown/witness │ │
│ │ ├── refinery ← gastown/refinery │ │
│ │ ├── Toast ← gastown/Toast │ │
│ │ └── crew/max ← gastown/crew/max │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
ADDRESS FORMATS:
mayor/ → Mayor inbox
<rig>/witness → Rig's Witness
<rig>/refinery → Rig's Refinery
<rig>/<polecat> → Polecat (e.g., gastown/Toast)
<rig>/crew/<name> → Crew worker (e.g., gastown/crew/max)
--human → Special: human overseer
COMMANDS:
inbox View your inbox
send Send a message
read Read a specific message
mark Mark messages read/unread`,
}
var mailSendCmd = &cobra.Command{

View File

@@ -30,7 +30,39 @@ var moleculeCmd = &cobra.Command{
Long: `Manage molecule workflow templates.
Molecules are composable workflow patterns stored as beads issues.
When instantiated on a parent issue, they create child beads forming a DAG.`,
When instantiated on a parent issue, they create child beads forming a DAG.
LIFECYCLE:
Proto (template)
▼ instantiate/bond
┌─────────────────┐
│ Mol (durable) │ ← tracked in .beads/
│ Wisp (ephemeral)│ ← tracked in .beads-wisp/
└────────┬────────┘
┌──────┴──────┐
▼ ▼
burn squash
(no record) (→ digest)
PHASE TRANSITIONS (for pluggable molecules):
┌─────────────┬─────────────┬─────────────┬─────────────────────┐
│ Phase │ Parallelism │ Blocks │ Purpose │
├─────────────┼─────────────┼─────────────┼─────────────────────┤
│ discovery │ full │ (nothing) │ Inventory, gather │
│ structural │ sequential │ discovery │ Big-picture review │
│ tactical │ parallel │ structural │ Detailed work │
│ synthesis │ single │ tactical │ Aggregate results │
└─────────────┴─────────────┴─────────────┴─────────────────────┘
COMMANDS:
catalog List available molecule protos
instantiate Create steps from a molecule template
progress Show execution progress of an instantiated molecule
status Show what's on an agent's hook
burn Discard molecule without creating a digest
squash Complete molecule and create a digest`,
}
var moleculeListCmd = &cobra.Command{
@@ -386,23 +418,35 @@ func runMoleculeList(cmd *cobra.Command, args []string) error {
return nil
}
// Create styled table
table := style.NewTable(
style.Column{Name: "ID", Width: 20},
style.Column{Name: "TITLE", Width: 35},
style.Column{Name: "STEPS", Width: 5, Align: style.AlignRight},
style.Column{Name: "SOURCE", Width: 10},
)
for _, mol := range entries {
sourceMarker := style.Dim.Render(fmt.Sprintf("[%s]", mol.Source))
stepCount := ""
// Format steps count
stepStr := ""
if mol.StepCount > 0 {
stepCount = fmt.Sprintf(" (%d steps)", mol.StepCount)
stepStr = fmt.Sprintf("%d", mol.StepCount)
}
statusMarker := ""
// Format title with status
title := mol.Title
if mol.Status == "closed" {
statusMarker = " " + style.Dim.Render("[closed]")
title = style.Dim.Render(mol.Title + " [closed]")
}
fmt.Printf(" %s: %s%s%s %s\n",
style.Bold.Render(mol.ID), mol.Title, stepCount, statusMarker, sourceMarker)
// Format source
source := style.Dim.Render(mol.Source)
table.AddRow(mol.ID, title, stepStr, source)
}
fmt.Print(table.Render())
return nil
}

View File

@@ -104,12 +104,17 @@ func runMQList(cmd *cobra.Command, args []string) error {
return nil
}
// Print header
fmt.Printf(" %-12s %-12s %-8s %-30s %-10s %s\n",
"ID", "STATUS", "PRIORITY", "BRANCH", "WORKER", "AGE")
fmt.Printf(" %s\n", strings.Repeat("-", 90))
// Create styled table
table := style.NewTable(
style.Column{Name: "ID", Width: 12},
style.Column{Name: "STATUS", Width: 12},
style.Column{Name: "PRI", Width: 4},
style.Column{Name: "BRANCH", Width: 28},
style.Column{Name: "WORKER", Width: 10},
style.Column{Name: "AGE", Width: 6, Align: style.AlignRight},
)
// Print each MR
// Add rows
for _, issue := range filtered {
fields := beads.ParseMRFields(issue)
@@ -127,9 +132,9 @@ func runMQList(cmd *cobra.Command, args []string) error {
styledStatus := displayStatus
switch displayStatus {
case "ready":
styledStatus = style.Bold.Render("ready")
styledStatus = style.Success.Render("ready")
case "in_progress":
styledStatus = style.Bold.Render("in_progress")
styledStatus = style.Warning.Render("active")
case "blocked":
styledStatus = style.Dim.Render("blocked")
case "closed":
@@ -144,13 +149,13 @@ func runMQList(cmd *cobra.Command, args []string) error {
worker = fields.Worker
}
// Truncate branch if too long
if len(branch) > 30 {
branch = branch[:27] + "..."
}
// Format priority
// Format priority with color
priority := fmt.Sprintf("P%d", issue.Priority)
if issue.Priority <= 1 {
priority = style.Error.Render(priority)
} else if issue.Priority == 2 {
priority = style.Warning.Render(priority)
}
// Calculate age
age := formatMRAge(issue.CreatedAt)
@@ -161,12 +166,24 @@ func runMQList(cmd *cobra.Command, args []string) error {
displayID = displayID[:12]
}
fmt.Printf(" %-12s %-12s %-8s %-30s %-10s %s\n",
displayID, styledStatus, priority, branch, worker, style.Dim.Render(age))
table.AddRow(displayID, styledStatus, priority, branch, worker, style.Dim.Render(age))
}
// Show blocking info if blocked
fmt.Print(table.Render())
// Show blocking details below table
for _, issue := range filtered {
displayStatus := issue.Status
if issue.Status == "open" && (len(issue.BlockedBy) > 0 || issue.BlockedByCount > 0) {
displayStatus = "blocked"
}
if displayStatus == "blocked" && len(issue.BlockedBy) > 0 {
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf(" (waiting on %s)", issue.BlockedBy[0])))
displayID := issue.ID
if len(displayID) > 12 {
displayID = displayID[:12]
}
fmt.Printf(" %s %s\n", style.Dim.Render(displayID+":"),
style.Dim.Render(fmt.Sprintf("waiting on %s", issue.BlockedBy[0])))
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/suggest"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -212,7 +213,9 @@ func runSessionStart(cmd *cobra.Command, args []string) error {
}
}
if !found {
return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName)
suggestions := suggest.FindSimilar(polecatName, r.Polecats, 3)
hint := fmt.Sprintf("Create with: gt polecat add %s/%s", rigName, polecatName)
return fmt.Errorf("%s", suggest.FormatSuggestion("Polecat", polecatName, suggestions, hint))
}
opts := session.StartOptions{

View File

@@ -19,6 +19,7 @@ import (
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/suggest"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -43,21 +44,39 @@ Based on the Universal Gas Town Propulsion Principle:
"If you find something on your hook, YOU RUN IT."
Arguments:
thing What to sling: proto name, issue ID, or epic ID
target Who to sling at: agent address (polecat/name, deacon/, etc.)
SLING MECHANICS:
┌─────────┐ ┌───────────────────────────────────────────┐
│ THING │─────▶│ SLING PIPELINE │
└─────────┘ │ │
proto │ 1. SPAWN Proto → Molecule instance │
issue │ 2. ASSIGN Molecule → Target agent │
epic │ 3. PIN Work → Agent's hook │
│ 4. IGNITE Session starts automatically │
│ │
│ ┌─────────────────────────────┐ │
│ │ 🪝 TARGET's HOOK │ │
│ │ └── [work lands here] │ │
│ └─────────────────────────────┘ │
└───────────────────────────────────────────┘
Agent runs the work!
THING TYPES:
proto Molecule template name (e.g., "feature", "bugfix")
issue Beads issue ID (e.g., "gt-abc123")
epic Epic ID for batch dispatch
TARGET FORMATS:
gastown/Toast → Polecat in rig
gastown/witness → Rig's Witness
gastown/refinery → Rig's Refinery
deacon/ → Global Deacon
Examples:
gt sling feature polecat/alpha # Spawn feature mol, sling to alpha
gt sling gt-xyz polecat/beta -m bugfix # Sling issue with bugfix workflow
gt sling patrol deacon/ --wisp # Ephemeral patrol wisp
gt sling gt-epic-batch refinery/ # Batch work to refinery
What Happens When You Sling:
1. SPAWN (if proto) - Create molecule from template
2. ASSIGN - Assign molecule/issue to target agent
3. PIN - Put work on agent's hook (pinned bead)
4. IGNITION - Agent wakes and runs the work`,
gt sling feature gastown/Toast # Spawn feature, sling to Toast
gt sling gt-abc gastown/Nux -m bugfix # Issue with workflow
gt sling patrol deacon/ --wisp # Ephemeral patrol wisp`,
Args: cobra.ExactArgs(2),
RunE: runSling,
}
@@ -361,7 +380,10 @@ func slingToPolecat(townRoot string, target *SlingTarget, thing *SlingThing) err
fmt.Printf("%s Fresh worktree created\n", style.Bold.Render("✓"))
} else if err == polecat.ErrPolecatNotFound {
if !slingCreate {
return fmt.Errorf("polecat '%s' not found (use --create to create)", polecatName)
suggestions := suggest.FindSimilar(polecatName, r.Polecats, 3)
hint := fmt.Sprintf("Or use --create to create: gt sling %s %s/%s --create",
thing.ID, target.Rig, polecatName)
return fmt.Errorf("%s", suggest.FormatSuggestion("Polecat", polecatName, suggestions, hint))
}
fmt.Printf("Creating polecat %s...\n", polecatName)
if _, err = polecatMgr.Add(polecatName); err != nil {

View File

@@ -39,7 +39,42 @@ var swarmCmd = &cobra.Command{
Long: `Manage coordinated multi-agent work units (swarms).
A swarm coordinates multiple polecats working on related tasks from a shared
base commit. Work is merged to an integration branch, then landed to main.`,
base commit. Work is merged to an integration branch, then landed to main.
SWARM LIFECYCLE:
epic (tasks)
┌────────────────────────────────────────────┐
│ SWARM │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Polecat │ │ Polecat │ │ Polecat │ │
│ │ Toast │ │ Nux │ │ Capable │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ integration/<epic> │ │
│ └───────────────────┬──────────────────┘ │
└──────────────────────┼────────────────────┘
▼ land
main
STATES:
creating → Swarm being set up
active → Workers executing tasks
merging → Work being integrated
landed → Successfully merged to main
cancelled → Swarm aborted
COMMANDS:
create Create a new swarm from an epic
status Show swarm progress
list List swarms in a rig
land Manually land completed swarm
cancel Cancel an active swarm
dispatch Assign next ready task to a worker`,
}
var swarmCreateCmd = &cobra.Command{

275
internal/style/table.go Normal file
View File

@@ -0,0 +1,275 @@
// Package style provides consistent terminal styling using Lipgloss.
package style
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// Column defines a table column with name and width.
type Column struct {
Name string
Width int
Align Alignment
Style lipgloss.Style
}
// Alignment specifies column text alignment.
type Alignment int
const (
AlignLeft Alignment = iota
AlignRight
AlignCenter
)
// Table provides styled table rendering.
type Table struct {
columns []Column
rows [][]string
headerSep bool
indent string
headerStyle lipgloss.Style
}
// NewTable creates a new table with the given columns.
func NewTable(columns ...Column) *Table {
return &Table{
columns: columns,
headerSep: true,
indent: " ",
headerStyle: Bold,
}
}
// SetIndent sets the left indent for the table.
func (t *Table) SetIndent(indent string) *Table {
t.indent = indent
return t
}
// SetHeaderSeparator enables/disables the header separator line.
func (t *Table) SetHeaderSeparator(enabled bool) *Table {
t.headerSep = enabled
return t
}
// AddRow adds a row of values to the table.
func (t *Table) AddRow(values ...string) *Table {
// Pad with empty strings if needed
for len(values) < len(t.columns) {
values = append(values, "")
}
t.rows = append(t.rows, values)
return t
}
// Render returns the formatted table string.
func (t *Table) Render() string {
if len(t.columns) == 0 {
return ""
}
var sb strings.Builder
// Render header
sb.WriteString(t.indent)
for i, col := range t.columns {
text := t.headerStyle.Render(col.Name)
sb.WriteString(t.pad(text, col.Name, col.Width, col.Align))
if i < len(t.columns)-1 {
sb.WriteString(" ")
}
}
sb.WriteString("\n")
// Render separator
if t.headerSep {
sb.WriteString(t.indent)
totalWidth := 0
for i, col := range t.columns {
totalWidth += col.Width
if i < len(t.columns)-1 {
totalWidth++ // space between columns
}
}
sb.WriteString(Dim.Render(strings.Repeat("─", totalWidth)))
sb.WriteString("\n")
}
// Render rows
for _, row := range t.rows {
sb.WriteString(t.indent)
for i, col := range t.columns {
val := ""
if i < len(row) {
val = row[i]
}
// Truncate if too long
plainVal := stripAnsi(val)
if len(plainVal) > col.Width {
val = plainVal[:col.Width-3] + "..."
}
// Apply column style if set
if col.Style.Value() != "" {
val = col.Style.Render(val)
}
sb.WriteString(t.pad(val, plainVal, col.Width, col.Align))
if i < len(t.columns)-1 {
sb.WriteString(" ")
}
}
sb.WriteString("\n")
}
return sb.String()
}
// pad pads text to width, accounting for ANSI escape sequences.
// styledText is the text with ANSI codes, plainText is without.
func (t *Table) pad(styledText, plainText string, width int, align Alignment) string {
plainLen := len(plainText)
if plainLen >= width {
return styledText
}
padding := width - plainLen
switch align {
case AlignRight:
return strings.Repeat(" ", padding) + styledText
case AlignCenter:
left := padding / 2
right := padding - left
return strings.Repeat(" ", left) + styledText + strings.Repeat(" ", right)
default: // AlignLeft
return styledText + strings.Repeat(" ", padding)
}
}
// stripAnsi removes ANSI escape sequences from a string.
func stripAnsi(s string) string {
var result strings.Builder
inEscape := false
for i := 0; i < len(s); i++ {
if s[i] == '\x1b' {
inEscape = true
continue
}
if inEscape {
if s[i] == 'm' {
inEscape = false
}
continue
}
result.WriteByte(s[i])
}
return result.String()
}
// PhaseTable renders the molecule phase transition table.
func PhaseTable() string {
return `
Phase Flow:
discovery ──┬──→ structural ──→ tactical ──→ synthesis
│ (sequential) (parallel) (single)
└─── (parallel)
┌─────────────┬─────────────┬─────────────┬─────────────────────┐
│ Phase │ Parallelism │ Blocks │ Purpose │
├─────────────┼─────────────┼─────────────┼─────────────────────┤
│ discovery │ full │ (nothing) │ Inventory, gather │
│ structural │ sequential │ discovery │ Big-picture review │
│ tactical │ parallel │ structural │ Detailed work │
│ synthesis │ single │ tactical │ Aggregate results │
└─────────────┴─────────────┴─────────────┴─────────────────────┘`
}
// MoleculeLifecycleASCII renders the molecule lifecycle diagram.
func MoleculeLifecycleASCII() string {
return `
Proto (template)
▼ bond
┌─────────────────┐
│ Mol (durable) │
│ Wisp (ephemeral)│
└────────┬────────┘
┌──────┴──────┐
▼ ▼
burn squash
(no record) (→ digest)`
}
// DAGProgress renders a DAG progress visualization.
// steps is a map of step name to status (done, in_progress, ready, blocked).
func DAGProgress(steps map[string]string, phases []string) string {
var sb strings.Builder
icons := map[string]string{
"done": "✓",
"in_progress": "⧖",
"ready": "○",
"blocked": "◌",
}
colors := map[string]lipgloss.Style{
"done": Success,
"in_progress": Warning,
"ready": Info,
"blocked": Dim,
}
for _, phase := range phases {
sb.WriteString(fmt.Sprintf(" %s\n", Bold.Render(phase)))
for name, status := range steps {
if strings.HasPrefix(name, phase+"-") || strings.HasPrefix(name, phase+"/") {
icon := icons[status]
style := colors[status]
stepName := strings.TrimPrefix(strings.TrimPrefix(name, phase+"-"), phase+"/")
sb.WriteString(fmt.Sprintf(" %s %s\n", style.Render(icon), stepName))
}
}
}
return sb.String()
}
// SuggestionBox renders a "did you mean" suggestion box.
func SuggestionBox(message string, suggestions []string, hint string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("\n%s %s\n", ErrorPrefix, message))
if len(suggestions) > 0 {
sb.WriteString("\n Did you mean?\n")
for _, s := range suggestions {
sb.WriteString(fmt.Sprintf(" • %s\n", s))
}
}
if hint != "" {
sb.WriteString(fmt.Sprintf("\n %s\n", Dim.Render(hint)))
}
return sb.String()
}
// ProgressBar renders a simple progress bar.
func ProgressBar(percent int, width int) string {
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
filled := (percent * width) / 100
empty := width - filled
bar := strings.Repeat("█", filled) + strings.Repeat("░", empty)
return fmt.Sprintf("[%s] %d%%", bar, percent)
}

232
internal/suggest/suggest.go Normal file
View File

@@ -0,0 +1,232 @@
// Package suggest provides fuzzy matching and "did you mean" suggestions.
package suggest
import (
"sort"
"strings"
"unicode"
)
// Match represents a potential match with its score.
type Match struct {
Value string
Score int
}
// FindSimilar finds similar strings from candidates that are close to target.
// Returns up to maxResults matches, sorted by similarity (best first).
func FindSimilar(target string, candidates []string, maxResults int) []string {
if len(candidates) == 0 || maxResults <= 0 {
return nil
}
target = strings.ToLower(target)
var matches []Match
for _, c := range candidates {
score := similarity(target, strings.ToLower(c))
if score > 0 {
matches = append(matches, Match{Value: c, Score: score})
}
}
// Sort by score descending
sort.Slice(matches, func(i, j int) bool {
return matches[i].Score > matches[j].Score
})
// Take top results
if len(matches) > maxResults {
matches = matches[:maxResults]
}
result := make([]string, len(matches))
for i, m := range matches {
result[i] = m.Value
}
return result
}
// similarity calculates a similarity score between two strings.
// Higher is more similar. Uses a combination of techniques:
// - Prefix matching (high weight)
// - Contains matching (medium weight)
// - Levenshtein distance (for close matches)
// - Common substring matching
func similarity(a, b string) int {
if a == b {
return 1000 // Exact match
}
score := 0
// Prefix matching - high value
prefixLen := commonPrefixLength(a, b)
if prefixLen > 0 {
score += prefixLen * 20
}
// Suffix matching
suffixLen := commonSuffixLength(a, b)
if suffixLen > 0 {
score += suffixLen * 10
}
// Contains matching
if strings.Contains(b, a) {
score += len(a) * 15
} else if strings.Contains(a, b) {
score += len(b) * 10
}
// Levenshtein distance for close matches
dist := levenshteinDistance(a, b)
maxLen := max(len(a), len(b))
if maxLen > 0 && dist <= maxLen/2 {
// Closer distance = higher score
score += (maxLen - dist) * 5
}
// Common characters bonus (order-independent)
common := commonChars(a, b)
if common > 0 {
score += common * 2
}
// Penalize very different lengths
lenDiff := abs(len(a) - len(b))
if lenDiff > 5 {
score -= lenDiff * 2
}
return score
}
// commonPrefixLength returns the length of the common prefix.
func commonPrefixLength(a, b string) int {
minLen := min(len(a), len(b))
for i := 0; i < minLen; i++ {
if a[i] != b[i] {
return i
}
}
return minLen
}
// commonSuffixLength returns the length of the common suffix.
func commonSuffixLength(a, b string) int {
minLen := min(len(a), len(b))
for i := 0; i < minLen; i++ {
if a[len(a)-1-i] != b[len(b)-1-i] {
return i
}
}
return minLen
}
// commonChars counts characters that appear in both strings.
func commonChars(a, b string) int {
aChars := make(map[rune]int)
for _, r := range a {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
aChars[r]++
}
}
common := 0
for _, r := range b {
if count, ok := aChars[r]; ok && count > 0 {
common++
aChars[r]--
}
}
return common
}
// levenshteinDistance calculates the edit distance between two strings.
func levenshteinDistance(a, b string) int {
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
// Create distance matrix
d := make([][]int, len(a)+1)
for i := range d {
d[i] = make([]int, len(b)+1)
d[i][0] = i
}
for j := range d[0] {
d[0][j] = j
}
// Fill the matrix
for i := 1; i <= len(a); i++ {
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
d[i][j] = min3(
d[i-1][j]+1, // deletion
d[i][j-1]+1, // insertion
d[i-1][j-1]+cost, // substitution
)
}
}
return d[len(a)][len(b)]
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min3(a, b, c int) int {
return min(min(a, b), c)
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
// FormatSuggestion formats an error message with suggestions.
func FormatSuggestion(entity, name string, suggestions []string, createHint string) string {
var sb strings.Builder
sb.WriteString(entity)
sb.WriteString(" '")
sb.WriteString(name)
sb.WriteString("' not found")
if len(suggestions) > 0 {
sb.WriteString("\n\n Did you mean?\n")
for _, s := range suggestions {
sb.WriteString(" • ")
sb.WriteString(s)
sb.WriteString("\n")
}
}
if createHint != "" {
sb.WriteString("\n ")
sb.WriteString(createHint)
}
return sb.String()
}

View File

@@ -0,0 +1,120 @@
package suggest
import (
"strings"
"testing"
)
func TestFindSimilar(t *testing.T) {
tests := []struct {
name string
target string
candidates []string
maxResults int
wantFirst string // expect this to be the first result
}{
{
name: "exact prefix match",
target: "toa",
candidates: []string{"Toast", "Nux", "Capable", "Ghost"},
maxResults: 3,
wantFirst: "Toast",
},
{
name: "typo match",
target: "Tosat",
candidates: []string{"Toast", "Nux", "Capable"},
maxResults: 3,
wantFirst: "Toast",
},
{
name: "case insensitive",
target: "TOAST",
candidates: []string{"Nux", "Toast", "Capable"},
maxResults: 1,
wantFirst: "Toast", // finds Toast even with different case
},
{
name: "no matches",
target: "xyz",
candidates: []string{"abc", "def"},
maxResults: 3,
wantFirst: "", // no good matches
},
{
name: "empty candidates",
target: "test",
candidates: []string{},
maxResults: 3,
wantFirst: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := FindSimilar(tt.target, tt.candidates, tt.maxResults)
if tt.wantFirst == "" {
if len(results) > 0 {
// Allow some results for partial matches, just check they're reasonable
return
}
return
}
if len(results) == 0 {
t.Errorf("FindSimilar(%q) returned no results, want first = %q", tt.target, tt.wantFirst)
return
}
if results[0] != tt.wantFirst {
t.Errorf("FindSimilar(%q) first result = %q, want %q", tt.target, results[0], tt.wantFirst)
}
})
}
}
func TestLevenshteinDistance(t *testing.T) {
tests := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "a", 1},
{"abc", "abc", 0},
{"abc", "abd", 1},
{"abc", "adc", 1},
{"abc", "abcd", 1},
{"kitten", "sitting", 3},
}
for _, tt := range tests {
t.Run(tt.a+"_"+tt.b, func(t *testing.T) {
got := levenshteinDistance(tt.a, tt.b)
if got != tt.want {
t.Errorf("levenshteinDistance(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestFormatSuggestion(t *testing.T) {
msg := FormatSuggestion("Polecat", "Tosat", []string{"Toast", "Ghost"}, "Create with: gt polecat add Tosat")
if !strings.Contains(msg, "Polecat") {
t.Errorf("FormatSuggestion missing entity name")
}
if !strings.Contains(msg, "Tosat") {
t.Errorf("FormatSuggestion missing target name")
}
if !strings.Contains(msg, "Did you mean?") {
t.Errorf("FormatSuggestion missing 'Did you mean?' section")
}
if !strings.Contains(msg, "Toast") {
t.Errorf("FormatSuggestion missing suggestion 'Toast'")
}
if !strings.Contains(msg, "Create with:") {
t.Errorf("FormatSuggestion missing hint")
}
}