From bf55c2c88482da3aeccc7e9333bbd1520c3b6ba7 Mon Sep 17 00:00:00 2001 From: rwrife Date: Thu, 2 Jul 2026 22:24:24 +0000 Subject: [PATCH] M4: ASCII art + render pipeline (#4) --- README.md | 8 +- cmd/root.go | 63 +++++- go.mod | 13 +- go.sum | 20 ++ internal/render/render.go | 348 ++++++++++++++++++++++++++++++++- internal/render/render_test.go | 250 +++++++++++++++++++++++ 6 files changed, 690 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 66b1eeb..2130ee3 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Not released as a prebuilt binary yet, but it already runs. With Go 1.23+ instal # Run straight from a clone git clone https://github.com/rwrife/commit-sprout cd commit-sprout -go run . # prints the current plant (an ASCII seedling for now) +go run . # renders the live plant for the repo you're in (add --no-color for plain ASCII) # Or install the binary onto your PATH go install github.com/rwrife/commit-sprout@latest @@ -68,6 +68,12 @@ tagged release. Watch the repo. 3. Persist a little state (`~/.config/commit-sprout/state.json`) so the plant remembers its best days and your streak. 4. Render ASCII. No cloud. No telemetry. We never read your code — just commit counts and times. + _(Implemented — M4. `internal/render` draws real ASCII art for every stage × health combo + (the plant visibly droops as it dries out), plus a caption block with stage, streak, last + commit, and a days-until-wilt nudge. Color is via `lipgloss`; the plain-ASCII path is used + automatically for `--no-color`, the `NO_COLOR` env var, and any non-TTY/piped output, so it + stays pipe-safe. `commit-sprout` now renders the live plant for your repo instead of a static + seedling — persisting its memory across runs arrives with M5.)_ ## Tech diff --git a/cmd/root.go b/cmd/root.go index f5fa330..cce9807 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,8 +10,11 @@ import ( "errors" "fmt" "os" + "time" + "github.com/mattn/go-isatty" "github.com/rwrife/commit-sprout/internal/gitstat" + "github.com/rwrife/commit-sprout/internal/plant" "github.com/rwrife/commit-sprout/internal/render" "github.com/spf13/cobra" ) @@ -24,6 +27,9 @@ var version = "dev" // showActivity backs the hidden --activity debug flag (M2 bridge). var showActivity bool +// noColor backs the --no-color flag, forcing the plain-ASCII render path. +var noColor bool + // rootCmd is the base command invoked as `commit-sprout` with no subcommand. var rootCmd = &cobra.Command{ Use: "commit-sprout", @@ -41,11 +47,60 @@ Run with no arguments to render the current plant.`, if showActivity { return printActivity(cmd) } - _, err := fmt.Fprintln(cmd.OutOrStdout(), render.Seedling()) - return err + return renderPlant(cmd) }, } +// renderPlant runs the full pipeline for the current repo: read git activity, +// compute the plant state, and render a framed ASCII plant to stdout. This is +// the real M4 default flow, replacing the M1 hard-coded seedling. +// +// State persistence (M5) is not wired yet, so a zero plant.State is used; the +// memory floor still behaves correctly from a fresh state, and M5 will load the +// remembered highest stage/streak here. +func renderPlant(cmd *cobra.Command) error { + out := cmd.OutOrStdout() + + act, err := gitstat.Read("", gitstat.DefaultWindowDays) + if err != nil { + if errors.Is(err, gitstat.ErrNotARepo) { + _, perr := fmt.Fprintln(out, + "Not a git repository \u2014 nothing growing here yet. "+ + "Run commit-sprout inside a repo.") + return perr + } + return err + } + + now := time.Now() + ps := plant.Compute(act, plant.State{}, now) + + frame := render.Frame(ps, render.Options{ + Color: useColor(cmd), + Now: now, + LastCommit: act.LastCommit, + }) + _, perr := fmt.Fprintln(out, frame) + return perr +} + +// useColor decides whether to take the color render path. Color is disabled by +// --no-color, by the NO_COLOR environment variable (any value), and whenever +// stdout is not a terminal (piped/redirected), keeping output pipe-safe. +func useColor(cmd *cobra.Command) bool { + if noColor { + return false + } + if _, ok := os.LookupEnv("NO_COLOR"); ok { + return false + } + f, ok := cmd.OutOrStdout().(*os.File) + if !ok { + return false + } + return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) +} + // printActivity renders the parsed Activity for the current repo. It is a // temporary M2 debugging aid behind the hidden --activity flag; the real // pipeline (activity -> plant -> render) arrives in later milestones. @@ -100,6 +155,10 @@ func init() { // Match the documented interface in README/PLAN: `--version` prints version. rootCmd.SetVersionTemplate("commit-sprout {{.Version}}\n") + // --no-color forces the plain-ASCII render path (also honored via NO_COLOR + // and automatically for non-TTY output). + rootCmd.Flags().BoolVar(&noColor, "no-color", false, "disable color output (plain ASCII)") + // Hidden M2 bridge: dump parsed git activity. Removed once M3+ consume it. rootCmd.Flags().BoolVar(&showActivity, "activity", false, "print parsed git activity (debug)") _ = rootCmd.Flags().MarkHidden("activity") diff --git a/go.mod b/go.mod index 8891537..41b146d 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,20 @@ module github.com/rwrife/commit-sprout go 1.23.4 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/charmbracelet/lipgloss v0.13.0 + github.com/mattn/go-isatty v0.0.20 + github.com/spf13/cobra v1.10.2 +) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.1.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0..d51eb49 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,30 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= +github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/render/render.go b/internal/render/render.go index 25e16a0..6359174 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -1,22 +1,354 @@ // Package render turns plant state into ASCII frames and prompt glyphs. // -// For M1 it exposes a single hard-coded seedling. Later milestones (M4) extend -// this with art for every stage/health combination plus a color path via -// lipgloss. Art is kept here as data so the rest of the program stays pure. +// Art lives here as data so the rest of the program stays pure: Frame selects a +// frame by (stage x health) and wraps it in a small caption block. A color path +// is available via lipgloss (Options.Color), with a clean plain-ASCII fallback +// that is used automatically for --no-color, NO_COLOR, and non-TTY output. +// +// Frame is deterministic given its inputs (including the reference time carried +// on PlantState via DaysSinceCommit and Streak), which keeps snapshot tests +// stable. package render -// seedling is the M1 hard-coded plant: a tiny sprout in a pot. +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/rwrife/commit-sprout/internal/plant" +) + +// seedling is the original M1 hard-coded plant: a tiny sprout in a pot. It is +// retained for backward compatibility (and the M1 stability test) and is the +// same art used for the healthy Seed frame. const seedling = ` \|/ | _|_ ( ) '-'` -// Seedling returns the hard-coded ASCII seedling frame. +// Options controls how a frame is rendered. +type Options struct { + // Color enables the lipgloss color path. When false (the default, and + // what callers pick for --no-color / NO_COLOR / piped output) rendering + // is pure plain ASCII with zero styling overhead. + Color bool + + // Now is the reference time used to phrase the "last commit" caption + // line (e.g. "today", "3 days ago"). The zero value falls back to + // time.Now at call time so tests can pin it for stable snapshots. + Now time.Time + + // LastCommit is the timestamp of the most recent commit, used for the + // caption. The zero value means "no commits yet" and is rendered as a + // friendly seed-planting nudge. + LastCommit time.Time +} + +// Seedling returns the hard-coded ASCII seedling frame (the healthy Seed art). // -// This is the M1 placeholder render. It intentionally takes no arguments; once -// the plant state machine (M3) lands, a Frame(PlantState) function will select -// the correct art based on stage and health. +// It predates the state machine and takes no arguments; new code should prefer +// Frame, which selects art for any stage/health combination. Kept for +// compatibility and as a stable, dependency-free entry point. func Seedling() string { return seedling } + +// Frame renders a full plant: the ASCII art for ps.Stage/ps.Health followed by +// a caption block (stage, streak, last commit / days-until-wilt). The art +// visibly differs across stages and wilt states, satisfying the M4 "done when". +// +// Rendering is deterministic given ps and opt. When opt.Color is false the +// output is plain ASCII suitable for pipes, files, and CI snapshots. +func Frame(ps plant.PlantState, opt Options) string { + art := artFor(ps.Stage, ps.Health) + caption := caption(ps, opt) + + var b strings.Builder + if opt.Color { + b.WriteString(colorizeArt(art, ps)) + b.WriteString("\n\n") + b.WriteString(colorizeCaption(caption, ps)) + } else { + b.WriteString(art) + b.WriteString("\n\n") + b.WriteString(caption) + } + return b.String() +} + +// PromptGlyph returns a compact one-line "glyph stage" suitable for a shell +// prompt or tmux status line. It is intentionally tiny and never multi-line. +// (M6 polishes prompt mode; this is the render-side primitive it will use.) +func PromptGlyph(ps plant.PlantState) string { + return fmt.Sprintf("%s %s", glyphFor(ps.Stage, ps.Health), ps.Stage) +} + +// glyphFor maps a stage/health to a single display rune for prompt mode. +func glyphFor(stage plant.Stage, health plant.Health) string { + if health == plant.Wilting { + return "\u2620" // skull-ish "this plant is in trouble" + } + switch stage { + case plant.Seed: + return "." + case plant.Sprout: + return "\u0264" // small hooked sprout + case plant.Leafy: + return "\u0644" // leafy stalk + case plant.Tall: + return "\u04ce" // tall stem + case plant.Blooming: + return "\u2740" // flower + default: + return "?" + } +} + +// caption builds the plain-text caption block under the art. It is a stable +// contract used by snapshot tests, so keep wording changes deliberate. +func caption(ps plant.PlantState, opt Options) string { + lines := []string{ + fmt.Sprintf("stage: %s (%s)", ps.Stage, ps.Health), + fmt.Sprintf("streak: %s", streakPhrase(ps.Streak)), + fmt.Sprintf("last: %s", lastCommitPhrase(ps, opt)), + } + if hint := wiltHint(ps); hint != "" { + lines = append(lines, " "+hint) + } + if ps.Mood != "" { + lines = append(lines, "") + lines = append(lines, ps.Mood) + } + return strings.Join(lines, "\n") +} + +// streakPhrase renders a streak count with correct pluralization. +func streakPhrase(streak int) string { + switch { + case streak <= 0: + return "no active streak" + case streak == 1: + return "1 day" + default: + return fmt.Sprintf("%d days", streak) + } +} + +// lastCommitPhrase describes when the last commit landed, in whole days. +func lastCommitPhrase(ps plant.PlantState, opt Options) string { + if ps.DaysSinceCommit < 0 || opt.LastCommit.IsZero() { + return "no commits yet \u2014 plant a seed with your first commit" + } + + now := opt.Now + if now.IsZero() { + now = time.Now() + } + stamp := opt.LastCommit.Format("2006-01-02") + + switch ps.DaysSinceCommit { + case 0: + return fmt.Sprintf("today (%s)", stamp) + case 1: + return fmt.Sprintf("yesterday (%s)", stamp) + default: + return fmt.Sprintf("%d days ago (%s)", ps.DaysSinceCommit, stamp) + } +} + +// wiltHint returns a short "days until wilt" style nudge, or "" when healthy +// with nothing to warn about. It mirrors plant's thresholds in spirit: thirsty +// plants get a countdown, wilting plants get a water nudge. +func wiltHint(ps plant.PlantState) string { + switch ps.Health { + case plant.Thirsty: + // Thirsty spans days 2-3 idle; wilting begins at day 4. So the + // days remaining before wilt is (4 - DaysSinceCommit), floored at 1. + remaining := 4 - ps.DaysSinceCommit + if remaining < 1 { + remaining = 1 + } + if remaining == 1 { + return "needs a commit today or it starts wilting" + } + return fmt.Sprintf("%d days until it wilts", remaining) + case plant.Wilting: + return "wilting \u2014 commit to revive it" + default: + return "" + } +} + +// artFor selects the ASCII frame for a stage/health combination. Wilting and +// (to a lesser degree) thirsty stages get drooped variants so the plant's +// condition is visible, not just captioned. Healthy stages use the upright art. +func artFor(stage plant.Stage, health plant.Health) string { + byHealth, ok := frames[stage] + if !ok { + byHealth = frames[plant.Seed] + } + if art, ok := byHealth[health]; ok { + return art + } + // Fall back to the healthy frame if a specific variant is missing. + return byHealth[plant.Healthy] +} + +// frames holds every stage's art keyed by health. Each frame is a fixed-size +// ASCII block; upright for Healthy, mild lean for Thirsty, and a clear droop +// for Wilting. Frames are data so new stages/variants are easy to add or edit. +var frames = map[plant.Stage]map[plant.Health]string{ + plant.Seed: { + plant.Healthy: seedling, + plant.Thirsty: " .|.\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " .,.\n" + + " .\n" + + " _._\n" + + " ( )\n" + + " '-'", + }, + plant.Sprout: { + plant.Healthy: " ,\n" + + " /|\n" + + " ' |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " .\n" + + " \\|\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: "\n" + + " \\,\n" + + " `.\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Leafy: { + plant.Healthy: " \\ | /\n" + + " \\|/\n" + + " --+--\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " \\ | \n" + + " \\| \n" + + " --+ \n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " . . .\n" + + " \\ / \n" + + " +._ \n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Tall: { + plant.Healthy: " \\\\|//\n" + + " \\|/\n" + + " --+--\n" + + " /|\\\n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " \\\\| \n" + + " \\| \n" + + " --+- \n" + + " /| \n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " . . .\n" + + " \\|/ \n" + + " ._+ \n" + + " \\\n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Blooming: { + plant.Healthy: " @ \\|/ @\n" + + " \\\\|//\n" + + " @--+--@\n" + + " /|\\\n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " @ \\| \n" + + " \\\\| \n" + + " @--+- \n" + + " /| \n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " * . . \n" + + " \\\\|/ \n" + + " .--+ \n" + + " /\\\n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, +} + +// --- color path (lipgloss) --------------------------------------------------- +// +// The color path is only taken when Options.Color is true. It is deliberately +// simple: a foliage color chosen by health, a neutral pot, and a dimmed +// caption. lipgloss itself also honors NO_COLOR / terminal profile, so this is +// a second layer of safety on top of the caller's plain-path decision. + +var ( + healthyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green + thirstyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow + wiltingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red + bloomStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("13")) // magenta accents + captionStyle = lipgloss.NewStyle().Faint(true) +) + +// colorizeArt applies a foliage color to the whole frame based on health, with +// a small bloom accent for a healthy blooming plant. +func colorizeArt(art string, ps plant.PlantState) string { + style := healthyStyle + switch ps.Health { + case plant.Thirsty: + style = thirstyStyle + case plant.Wilting: + style = wiltingStyle + default: + if ps.Stage == plant.Blooming { + style = bloomStyle + } + } + return style.Render(art) +} + +// colorizeCaption dims the caption block so the plant stays the focal point. +func colorizeCaption(caption string, _ plant.PlantState) string { + return captionStyle.Render(caption) +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 12ca679..f454d88 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -3,6 +3,9 @@ package render import ( "strings" "testing" + "time" + + "github.com/rwrife/commit-sprout/internal/plant" ) func TestSeedlingNotEmpty(t *testing.T) { @@ -18,3 +21,250 @@ func TestSeedlingIsStable(t *testing.T) { t.Errorf("Seedling() did not match the seedling constant") } } + +// TestFramesExistForEveryStageAndHealth ensures the art table is complete: a +// non-empty frame for every (stage x health) combination the state machine can +// produce. A missing entry would fall back and hide a bug, so assert directly. +func TestFramesExistForEveryStageAndHealth(t *testing.T) { + stages := []plant.Stage{plant.Seed, plant.Sprout, plant.Leafy, plant.Tall, plant.Blooming} + healths := []plant.Health{plant.Healthy, plant.Thirsty, plant.Wilting} + + for _, s := range stages { + byHealth, ok := frames[s] + if !ok { + t.Fatalf("no frames registered for stage %s", s) + } + for _, h := range healths { + art, ok := byHealth[h] + if !ok { + t.Errorf("stage %s missing frame for health %s", s, h) + continue + } + if strings.TrimSpace(art) == "" { + t.Errorf("stage %s / health %s frame is blank", s, h) + } + } + } +} + +// TestArtVariesAcrossStages is the heart of the M4 "done when": running the +// tool must show a plant that visibly differs across stages. Assert that every +// healthy stage's art is distinct from every other. +func TestArtVariesAcrossStages(t *testing.T) { + stages := []plant.Stage{plant.Seed, plant.Sprout, plant.Leafy, plant.Tall, plant.Blooming} + seen := map[string]plant.Stage{} + for _, s := range stages { + art := artFor(s, plant.Healthy) + if prev, dup := seen[art]; dup { + t.Errorf("stages %s and %s render identical art; they should differ", prev, s) + } + seen[art] = s + } +} + +// TestArtVariesAcrossHealth ensures wilt states are visible in the art, not +// only the caption: for each stage, the three health frames must be distinct. +func TestArtVariesAcrossHealth(t *testing.T) { + stages := []plant.Stage{plant.Seed, plant.Sprout, plant.Leafy, plant.Tall, plant.Blooming} + for _, s := range stages { + h := artFor(s, plant.Healthy) + th := artFor(s, plant.Thirsty) + w := artFor(s, plant.Wilting) + if h == th { + t.Errorf("stage %s: healthy and thirsty art are identical", s) + } + if h == w { + t.Errorf("stage %s: healthy and wilting art are identical", s) + } + if th == w { + t.Errorf("stage %s: thirsty and wilting art are identical", s) + } + } +} + +// TestPlainFrameIsColorFree verifies the plain path (Color:false) emits no ANSI +// escape sequences, so piped/CI output stays clean. +func TestPlainFrameIsColorFree(t *testing.T) { + ps := plant.PlantState{ + Stage: plant.Blooming, + Health: plant.Healthy, + Streak: 14, + DaysSinceCommit: 0, + Mood: "In full bloom. Absolute unit of a plant.", + } + out := Frame(ps, Options{ + Color: false, + Now: time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC), + LastCommit: time.Date(2026, 7, 2, 9, 0, 0, 0, time.UTC), + }) + if strings.Contains(out, "\x1b[") { + t.Errorf("plain Frame contained ANSI escape codes:\n%q", out) + } +} + +// TestFrameSnapshotHealthyLeafy pins the exact plain-text render of a healthy +// leafy plant so accidental art/caption drift is caught. +func TestFrameSnapshotHealthyLeafy(t *testing.T) { + ps := plant.PlantState{ + Stage: plant.Leafy, + Health: plant.Healthy, + Streak: 5, + DaysSinceCommit: 0, + Mood: "Leafing out nicely. Keep it up.", + } + got := Frame(ps, Options{ + Color: false, + Now: time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC), + LastCommit: time.Date(2026, 7, 2, 8, 30, 0, 0, time.UTC), + }) + + want := strings.Join([]string{ + ` \ | /`, + ` \|/`, + ` --+--`, + ` |`, + ` _|_`, + ` ( )`, + ` '-'`, + ``, + `stage: leafy (healthy)`, + `streak: 5 days`, + `last: today (2026-07-02)`, + ``, + `Leafing out nicely. Keep it up.`, + }, "\n") + + if got != want { + t.Errorf("healthy leafy snapshot mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// TestFrameSnapshotWiltingTall pins a wilting tall plant, including the wilt +// hint line and "days ago" phrasing. +func TestFrameSnapshotWiltingTall(t *testing.T) { + ps := plant.PlantState{ + Stage: plant.Tall, + Health: plant.Wilting, + Streak: 0, + DaysSinceCommit: 5, + Mood: "Parched. A commit would really help right now.", + } + got := Frame(ps, Options{ + Color: false, + Now: time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC), + LastCommit: time.Date(2026, 6, 27, 10, 0, 0, 0, time.UTC), + }) + + want := strings.Join([]string{ + ` . . .`, + ` \|/ `, + ` ._+ `, + ` \`, + ` |`, + ` |`, + ` _|_`, + ` ( )`, + ` '-'`, + ``, + `stage: tall (wilting)`, + `streak: no active streak`, + `last: 5 days ago (2026-06-27)`, + ` wilting ` + "\u2014" + ` commit to revive it`, + ``, + `Parched. A commit would really help right now.`, + }, "\n") + + if got != want { + t.Errorf("wilting tall snapshot mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// TestFrameNoCommitsCaption checks the friendly seed messaging when there is no +// commit history at all (DaysSinceCommit == -1, zero LastCommit). +func TestFrameNoCommitsCaption(t *testing.T) { + ps := plant.PlantState{ + Stage: plant.Seed, + Health: plant.Healthy, + Streak: 0, + DaysSinceCommit: -1, + Mood: "Just a seed. Commit to make it grow.", + } + got := Frame(ps, Options{Color: false}) + + if !strings.Contains(got, "plant a seed with your first commit") { + t.Errorf("expected first-commit nudge in caption, got:\n%s", got) + } + if strings.Contains(got, "days ago") { + t.Errorf("no-commit caption should not mention days ago:\n%s", got) + } +} + +// TestWiltHintThirstyCountdown verifies the thirsty countdown phrasing across +// the thirsty band (days 2 and 3 idle). +func TestWiltHintThirstyCountdown(t *testing.T) { + cases := []struct { + days int + want string + }{ + {days: 2, want: "2 days until it wilts"}, + {days: 3, want: "needs a commit today or it starts wilting"}, + } + for _, tc := range cases { + ps := plant.PlantState{Stage: plant.Leafy, Health: plant.Thirsty, DaysSinceCommit: tc.days} + if got := wiltHint(ps); got != tc.want { + t.Errorf("wiltHint(days=%d) = %q, want %q", tc.days, got, tc.want) + } + } +} + +// TestStreakPhrasePluralization guards the streak wording edges. +func TestStreakPhrasePluralization(t *testing.T) { + cases := map[int]string{ + 0: "no active streak", + 1: "1 day", + 2: "2 days", + 14: "14 days", + } + for in, want := range cases { + if got := streakPhrase(in); got != want { + t.Errorf("streakPhrase(%d) = %q, want %q", in, got, want) + } + } +} + +// TestPromptGlyphSingleLine ensures prompt glyphs never span multiple lines and +// include the stage name. +func TestPromptGlyphSingleLine(t *testing.T) { + ps := plant.PlantState{Stage: plant.Blooming, Health: plant.Healthy} + got := PromptGlyph(ps) + if strings.Contains(got, "\n") { + t.Errorf("PromptGlyph must be single-line, got %q", got) + } + if !strings.Contains(got, "blooming") { + t.Errorf("PromptGlyph should include stage name, got %q", got) + } +} + +// TestColorFrameIsRenderable makes sure the color path produces non-empty, +// still-contains-art output without panicking. (Actual ANSI depends on the +// detected terminal profile, which is intentionally environment-driven.) +func TestColorFrameIsRenderable(t *testing.T) { + ps := plant.PlantState{ + Stage: plant.Sprout, + Health: plant.Healthy, + Streak: 1, + DaysSinceCommit: 0, + Mood: "A fresh sprout!", + } + out := Frame(ps, Options{ + Color: true, + Now: time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC), + LastCommit: time.Date(2026, 7, 2, 9, 0, 0, 0, time.UTC), + }) + if strings.TrimSpace(out) == "" { + t.Fatal("color Frame returned empty output") + } + if !strings.Contains(out, "sprout") { + t.Errorf("color Frame should still contain caption text, got:\n%s", out) + } +}