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. Long: `Send and receive messages between agents.
The mail system allows Mayor, polecats, and the Refinery to communicate. 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{ var mailSendCmd = &cobra.Command{

View File

@@ -30,7 +30,39 @@ var moleculeCmd = &cobra.Command{
Long: `Manage molecule workflow templates. Long: `Manage molecule workflow templates.
Molecules are composable workflow patterns stored as beads issues. 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{ var moleculeListCmd = &cobra.Command{
@@ -386,23 +418,35 @@ func runMoleculeList(cmd *cobra.Command, args []string) error {
return nil 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 { for _, mol := range entries {
sourceMarker := style.Dim.Render(fmt.Sprintf("[%s]", mol.Source)) // Format steps count
stepStr := ""
stepCount := ""
if mol.StepCount > 0 { 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" { if mol.Status == "closed" {
statusMarker = " " + style.Dim.Render("[closed]") title = style.Dim.Render(mol.Title + " [closed]")
} }
fmt.Printf(" %s: %s%s%s %s\n", // Format source
style.Bold.Render(mol.ID), mol.Title, stepCount, statusMarker, sourceMarker) source := style.Dim.Render(mol.Source)
table.AddRow(mol.ID, title, stepStr, source)
} }
fmt.Print(table.Render())
return nil return nil
} }

View File

@@ -104,12 +104,17 @@ func runMQList(cmd *cobra.Command, args []string) error {
return nil return nil
} }
// Print header // Create styled table
fmt.Printf(" %-12s %-12s %-8s %-30s %-10s %s\n", table := style.NewTable(
"ID", "STATUS", "PRIORITY", "BRANCH", "WORKER", "AGE") style.Column{Name: "ID", Width: 12},
fmt.Printf(" %s\n", strings.Repeat("-", 90)) 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 { for _, issue := range filtered {
fields := beads.ParseMRFields(issue) fields := beads.ParseMRFields(issue)
@@ -127,9 +132,9 @@ func runMQList(cmd *cobra.Command, args []string) error {
styledStatus := displayStatus styledStatus := displayStatus
switch displayStatus { switch displayStatus {
case "ready": case "ready":
styledStatus = style.Bold.Render("ready") styledStatus = style.Success.Render("ready")
case "in_progress": case "in_progress":
styledStatus = style.Bold.Render("in_progress") styledStatus = style.Warning.Render("active")
case "blocked": case "blocked":
styledStatus = style.Dim.Render("blocked") styledStatus = style.Dim.Render("blocked")
case "closed": case "closed":
@@ -144,13 +149,13 @@ func runMQList(cmd *cobra.Command, args []string) error {
worker = fields.Worker worker = fields.Worker
} }
// Truncate branch if too long // Format priority with color
if len(branch) > 30 {
branch = branch[:27] + "..."
}
// Format priority
priority := fmt.Sprintf("P%d", issue.Priority) 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 // Calculate age
age := formatMRAge(issue.CreatedAt) age := formatMRAge(issue.CreatedAt)
@@ -161,12 +166,24 @@ func runMQList(cmd *cobra.Command, args []string) error {
displayID = displayID[:12] displayID = displayID[:12]
} }
fmt.Printf(" %-12s %-12s %-8s %-30s %-10s %s\n", table.AddRow(displayID, styledStatus, priority, branch, worker, style.Dim.Render(age))
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 { 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/rig"
"github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/suggest"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
) )
@@ -212,7 +213,9 @@ func runSessionStart(cmd *cobra.Command, args []string) error {
} }
} }
if !found { 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{ opts := session.StartOptions{

View File

@@ -19,6 +19,7 @@ import (
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/suggest"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace" "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." "If you find something on your hook, YOU RUN IT."
Arguments: SLING MECHANICS:
thing What to sling: proto name, issue ID, or epic ID ┌─────────┐ ┌───────────────────────────────────────────┐
target Who to sling at: agent address (polecat/name, deacon/, etc.) │ 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: Examples:
gt sling feature polecat/alpha # Spawn feature mol, sling to alpha gt sling feature gastown/Toast # Spawn feature, sling to Toast
gt sling gt-xyz polecat/beta -m bugfix # Sling issue with bugfix workflow gt sling gt-abc gastown/Nux -m bugfix # Issue with workflow
gt sling patrol deacon/ --wisp # Ephemeral patrol wisp 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`,
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
RunE: runSling, 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("✓")) fmt.Printf("%s Fresh worktree created\n", style.Bold.Render("✓"))
} else if err == polecat.ErrPolecatNotFound { } else if err == polecat.ErrPolecatNotFound {
if !slingCreate { 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) fmt.Printf("Creating polecat %s...\n", polecatName)
if _, err = polecatMgr.Add(polecatName); err != nil { 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). Long: `Manage coordinated multi-agent work units (swarms).
A swarm coordinates multiple polecats working on related tasks from a shared 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{ 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")
}
}