diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b65c84b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Release + +# Cut a release when a version tag is pushed, e.g.: +# git tag v0.1.0 && git push origin v0.1.0 +# +# GoReleaser (config in .goreleaser.yaml) cross-compiles static binaries for +# linux/macos/windows (amd64 + arm64) and publishes a GitHub Release with +# archives and checksums. The version is injected into `commit-sprout --version` +# from the tag. +on: + push: + tags: + - "v*" + +permissions: + # Needed for GoReleaser to create the GitHub Release and upload assets. + contents: write + +jobs: + goreleaser: + name: goreleaser + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Full history + tags so the changelog and version resolve correctly. + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + check-latest: true + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + # Pin a concrete v2 line so releases are reproducible and match the + # config syntax validated in CI/dev. + version: "v2.4.4" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..94aa1da --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,76 @@ +# GoReleaser configuration for commit-sprout. +# +# On a pushed tag (vX.Y.Z) the release workflow (.github/workflows/release.yml) +# runs `goreleaser release`, which cross-compiles a static single binary for +# Linux, macOS, and Windows and publishes a GitHub Release with archives and +# checksums. The build is CGO-free so every artifact is a self-contained binary +# with no runtime to install -- exactly what a prompt-hot-path tool wants. +# +# Validate locally without publishing: +# goreleaser check +# goreleaser release --snapshot --clean +version: 2 + +project_name: commit-sprout + +before: + hooks: + - go mod tidy + +builds: + - id: commit-sprout + main: . + binary: commit-sprout + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + # Trim paths and inject the version/commit/date into cmd.version so + # `commit-sprout --version` reports the released tag. + flags: + - -trimpath + ldflags: + - -s -w + - -X github.com/rwrife/commit-sprout/cmd.version={{ .Version }} + +archives: + - id: commit-sprout + # Keep names predictable: commit-sprout___. + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + format: tar.gz + # Windows users generally expect a zip. + format_overrides: + - goos: windows + format: zip + files: + - README.md + - LICENSE + +checksum: + name_template: "checksums.txt" + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + use: github + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" + - "Merge pull request" + - "Merge branch" + +release: + draft: false + prerelease: auto + name_template: "commit-sprout {{ .Tag }}" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4c7da5b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 rwrife + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b316dc9..e0d2fa7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ `commit-sprout` reads your git history and turns "did I actually ship anything?" into something you can *see*. It's local-first, single-binary, and has opinions about your commit cadence. -> Status: **early days** — see [PLAN.md](./PLAN.md) and the milestone issues. +> Status: **v0.1** — the plant renders, reacts to your commits, and drops into +> your prompt. See [PLAN.md](./PLAN.md) and the milestone issues for what's next. ## Why @@ -29,13 +30,48 @@ dopamine as a contribution streak, but with a face (well, leaves). ```bash commit-sprout # render the current plant for the repo you're in commit-sprout status # streak, last commit, days until it wilts -commit-sprout --prompt # compact glyph for your shell prompt / tmux status +commit-sprout prompt # compact glyph for your shell prompt / tmux status +commit-sprout --prompt # same thing, as a flag (handy inside a prompt string) commit-sprout water # (planned) buy a grace day before wilting ``` +`status` prints a plain, script-friendly summary: + +```text +stage: leafy (healthy) +streak: 6 days (best: 11 days) +last: today (2026-07-04) +health: healthy — 2 days of cushion before it gets thirsty +``` + +`prompt` prints a single glyph + stage (e.g. `ل leafy`). It is built for the +hot path: **read-only by default** (a prompt renders on every command, so it +skips the state write unless you pass `--save`) and it **fails silently outside +a git repo** — it prints nothing and exits `0`, so it can never break your +shell. Drop it in and forget it. + ## Install -Not released as a prebuilt binary yet, but it already runs. With Go 1.23+ installed: +### Prebuilt binary (recommended) + +Grab a static, single-file binary for your OS/arch from the +[latest release](https://github.com/rwrife/commit-sprout/releases/latest) — +builds are published for Linux, macOS, and Windows (amd64 + arm64), with +checksums. No runtime to install. Unpack it and drop `commit-sprout` on your +`PATH`, e.g.: + +```bash +# Linux/macOS example (adjust the asset name to your OS/arch) +curl -sSL -o commit-sprout.tar.gz \ + https://github.com/rwrife/commit-sprout/releases/latest/download/commit-sprout__linux_amd64.tar.gz +tar -xzf commit-sprout.tar.gz commit-sprout +install -m 0755 commit-sprout ~/.local/bin/ # or anywhere on your PATH +commit-sprout --version +``` + +### From source + +With Go 1.23+ installed: ```bash # Run straight from a clone @@ -49,8 +85,50 @@ commit-sprout # same thing commit-sprout --version ``` -Once M6 lands there'll be a single static binary for Windows/macOS/Linux and a -tagged release. Watch the repo. +## Put it in your shell prompt + +`commit-sprout prompt` emits one line (`glyph stage`) and is safe to call on +every prompt render — it does no disk writes by default and stays silent outside +a git repo. A few ways to wire it up: + +**starship** (`~/.config/starship.toml`) — add a custom module: + +```toml +[custom.commit_sprout] +command = "commit-sprout prompt" +when = "git rev-parse --is-inside-work-tree" +shell = ["sh", "--norc"] +format = "[$output]($style) " +style = "green" +``` + +**tmux** (`~/.tmux.conf`) — show it in the status line (polled, so keep the +interval sane): + +```tmux +set -g status-interval 60 +set -g status-right "#(cd #{pane_current_path} && commit-sprout prompt) | %H:%M" +``` + +**bash** (`~/.bashrc`) — prepend the glyph to your prompt: + +```bash +__commit_sprout() { commit-sprout prompt 2>/dev/null; } +PROMPT_COMMAND='PS1_SPROUT=$(__commit_sprout)' +PS1='${PS1_SPROUT:+$PS1_SPROUT }\u@\h:\w\$ ' +``` + +**zsh** (`~/.zshrc`) — same idea with a precmd hook: + +```zsh +autoload -Uz add-zsh-hook +__commit_sprout() { psvar[1]="$(commit-sprout prompt 2>/dev/null)"; } +add-zsh-hook precmd __commit_sprout +setopt prompt_subst +PROMPT='%(1V.%1v .)%n@%m:%~%# ' +``` + +All of these degrade cleanly: no repo, no glyph, no error. ## How it works (planned) @@ -92,7 +170,7 @@ See [PLAN.md](./PLAN.md) for the full plan, milestones (M1–M6), and the v0.2+ ## License -MIT (see [LICENSE](./LICENSE) once added). +MIT — see [LICENSE](./LICENSE). --- diff --git a/cmd/prompt.go b/cmd/prompt.go new file mode 100644 index 0000000..6b6a61c --- /dev/null +++ b/cmd/prompt.go @@ -0,0 +1,79 @@ +// Prompt subcommand: emit a single compact line -- a glyph plus the stage name +// -- suitable for embedding in a shell prompt (bash/zsh $PROMPT), a tmux status +// line, or a starship custom module. +// +// Design constraints (M6): prompt mode must be fast and must never block or +// break the shell. Two things follow from that: +// +// - It is read-only by default. A prompt renders on every command you run, so +// writing state each time would be needless disk churn; prompt mode skips +// the save unless you explicitly pass --save. (The root command and status +// still persist normally.) +// - It fails soft. Outside a git repo -- or if git errors for any reason -- +// it prints nothing and exits 0, so a misconfigured prompt never dumps an +// error into your shell. Embed it and forget it. +package cmd + +import ( + "errors" + "fmt" + + "github.com/rwrife/commit-sprout/internal/gitstat" + "github.com/rwrife/commit-sprout/internal/render" + "github.com/spf13/cobra" +) + +// promptSave opts prompt mode back into persisting state. Off by default so the +// hot path (a prompt on every command) does no disk writes. +var promptSave bool + +// promptCmd implements `commit-sprout prompt`. +var promptCmd = &cobra.Command{ + Use: "prompt", + Short: "Emit a one-line glyph for a shell prompt, tmux, or starship", + Long: `Print a single compact line -- a glyph plus the plant's stage -- meant to +be embedded in a shell prompt, a tmux status line, or a starship custom module. + +Prompt mode is built for the hot path: it is read-only by default (no state is +written, since a prompt renders constantly) and fails silently outside a git +repo, printing nothing and exiting 0 so it can never break your shell. Pass +--save if you do want a prompt render to update the plant's memory.`, + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + return renderPrompt(cmd) + }, +} + +// renderPrompt prints the compact glyph line. It swallows the not-a-repo case +// (and, defensively, does not surface git errors into the prompt) so that a +// prompt integration is always safe to leave in place. +func renderPrompt(cmd *cobra.Command) error { + r, err := runPipeline() + if err != nil { + if errors.Is(err, gitstat.ErrNotARepo) { + // Silent: nothing growing here, so contribute nothing to the prompt. + return nil + } + // Any other git failure also stays silent in prompt mode; a broken + // prompt is worse than a missing glyph. + return nil + } + + if _, perr := fmt.Fprintln(cmd.OutOrStdout(), render.PromptGlyph(r.plant)); perr != nil { + return perr + } + + // Prompt mode only persists when explicitly asked (--save). The --no-save + // persistent flag still wins if set. + if promptSave { + persist(r) + } + return nil +} + +func init() { + // --save re-enables persistence for prompt mode (off by default). + promptCmd.Flags().BoolVar(&promptSave, "save", false, "persist plant state on this prompt render (off by default)") +} diff --git a/cmd/root.go b/cmd/root.go index c77ce05..5c96a50 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,9 +1,10 @@ // Package cmd defines the cobra command tree for commit-sprout. // -// For M1 the root command renders a single hard-coded ASCII seedling. M2 adds -// a hidden --activity flag that prints parsed git activity via internal/gitstat -// as a debugging bridge. Later milestones wire the plant state machine, -// rendering, and persistent state into the default flow. +// The root command renders the full ASCII plant for the current repo. Two +// focused subcommands ride on the same pipeline: `status` prints a compact, +// plain-text summary (streak, last commit, days-until-wilt) and `prompt` +// (also reachable as the `--prompt` flag on the root) emits a single-line +// glyph for shell prompts, tmux, and starship. package cmd import ( @@ -23,6 +24,8 @@ import ( // version is the build version. It is overridable at build time via: // // go build -ldflags "-X github.com/rwrife/commit-sprout/cmd.version=v0.1.0" +// +// GoReleaser sets it automatically from the git tag (see .goreleaser.yaml). var version = "dev" // showActivity backs the hidden --activity debug flag (M2 bridge). @@ -36,6 +39,10 @@ var noColor bool // update the remembered plant. var noSave bool +// promptMode backs the --prompt flag on the root command, a convenience alias +// for the `prompt` subcommand so it drops cleanly into a shell prompt string. +var promptMode bool + // rootCmd is the base command invoked as `commit-sprout` with no subcommand. var rootCmd = &cobra.Command{ Use: "commit-sprout", @@ -44,7 +51,9 @@ var rootCmd = &cobra.Command{ anything?" into something you can see. Commit today and it sprouts; keep a streak and it blooms; ghost your repo and it wilts. -Run with no arguments to render the current plant.`, +Run with no arguments to render the current plant. Use "status" for a compact +text summary or "prompt" (or --prompt) for a one-line glyph you can embed in a +shell prompt, tmux, or starship.`, // SilenceUsage/SilenceErrors keep output clean; we print errors ourselves. SilenceUsage: true, SilenceErrors: true, @@ -53,34 +62,37 @@ Run with no arguments to render the current plant.`, if showActivity { return printActivity(cmd) } + if promptMode { + return renderPrompt(cmd) + } return renderPlant(cmd) }, } -// renderPlant runs the full pipeline for the current repo: load remembered -// state, read git activity, compute the plant state, render a framed ASCII -// plant to stdout, then persist the updated memory back to disk. -// -// Persistence is best-effort and never blocks rendering: a load failure falls -// back to a fresh plant, and a save failure is reported on stderr but does not -// fail the command (so a read-only home dir or full disk still shows the -// plant). The --no-save flag skips the write entirely. -func renderPlant(cmd *cobra.Command) error { - out := cmd.OutOrStdout() +// pipelineResult bundles everything the render/status/prompt paths need out of +// one pass over the repo: the raw activity, the loaded persisted state (and its +// path), and the computed plant state as of `now`. +type pipelineResult struct { + activity gitstat.Activity + persisted store.State + statePath string + pathErr error + plant plant.PlantState + now time.Time +} +// runPipeline performs the shared read half of every command: read git +// activity, load remembered state, and compute the plant state. It never +// writes; callers decide whether to persist afterward via persist(). +// +// It returns gitstat.ErrNotARepo unwrapped so callers can special-case the +// "not a repo" message in their own voice. +func runPipeline() (pipelineResult, error) { 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 + return pipelineResult{}, err } - // Load remembered state (highest stage, best streak). A read error degrades - // to a fresh plant but is surfaced on stderr for visibility. statePath, pathErr := store.DefaultPath() persisted := store.DefaultState() if pathErr == nil { @@ -94,22 +106,62 @@ func renderPlant(cmd *cobra.Command) error { now := time.Now() ps := plant.Compute(act, persisted.Plant(), now) - frame := render.Frame(ps, render.Options{ + return pipelineResult{ + activity: act, + persisted: persisted, + statePath: statePath, + pathErr: pathErr, + plant: ps, + now: now, + }, nil +} + +// persist folds the computed plant state back into the remembered file, unless +// --no-save was set or the state path could not be resolved. It is best-effort: +// a write failure is surfaced on stderr but never fails the command, so a +// read-only home dir or full disk still lets the plant render. +func persist(r pipelineResult) { + if noSave || r.pathErr != nil { + return + } + updated := r.persisted.FromPlant(r.plant, "", r.activity.LastCommit) + if serr := store.Save(r.statePath, updated); serr != nil { + fmt.Fprintln(os.Stderr, "commit-sprout: warning:", serr) + } +} + +// notARepoMessage prints the friendly "nothing growing here" line and returns +// any write error. It centralizes the wording shared by every command. +func notARepoMessage(cmd *cobra.Command) error { + _, perr := fmt.Fprintln(cmd.OutOrStdout(), + "Not a git repository \u2014 nothing growing here yet. "+ + "Run commit-sprout inside a repo.") + return perr +} + +// renderPlant runs the full pipeline for the current repo and renders a framed +// ASCII plant to stdout, then persists the updated memory back to disk. +func renderPlant(cmd *cobra.Command) error { + out := cmd.OutOrStdout() + + r, err := runPipeline() + if err != nil { + if errors.Is(err, gitstat.ErrNotARepo) { + return notARepoMessage(cmd) + } + return err + } + + frame := render.Frame(r.plant, render.Options{ Color: useColor(cmd), - Now: now, - LastCommit: act.LastCommit, + Now: r.now, + LastCommit: r.activity.LastCommit, }) if _, perr := fmt.Fprintln(out, frame); perr != nil { return perr } - // Persist the updated memory. Best-effort: warn but don't fail the command. - if !noSave && pathErr == nil { - updated := persisted.FromPlant(ps, "", act.LastCommit) - if serr := store.Save(statePath, updated); serr != nil { - fmt.Fprintln(os.Stderr, "commit-sprout: warning:", serr) - } - } + persist(r) return nil } @@ -137,10 +189,7 @@ func printActivity(cmd *cobra.Command) error { act, err := gitstat.Read("", gitstat.DefaultWindowDays) if err != nil { if errors.Is(err, gitstat.ErrNotARepo) { - _, perr := fmt.Fprintln(cmd.OutOrStdout(), - "Not a git repository \u2014 nothing growing here yet. "+ - "Run commit-sprout inside a repo.") - return perr + return notARepoMessage(cmd) } return err } @@ -189,9 +238,17 @@ func init() { rootCmd.Flags().BoolVar(&noColor, "no-color", false, "disable color output (plain ASCII)") // --no-save renders without persisting state (read-only run). - rootCmd.Flags().BoolVar(&noSave, "no-save", false, "do not persist plant state for this run") + rootCmd.PersistentFlags().BoolVar(&noSave, "no-save", false, "do not persist plant state for this run") + + // --prompt is a convenience alias for the `prompt` subcommand so the glyph + // drops straight into a shell prompt string. + rootCmd.Flags().BoolVar(&promptMode, "prompt", false, "emit a one-line glyph for shell prompt / tmux / starship") // 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") + + // Subcommands (M6): status + prompt ride the shared pipeline. + rootCmd.AddCommand(statusCmd) + rootCmd.AddCommand(promptCmd) } diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..db8075e --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,134 @@ +// Status subcommand: a compact, plain-text summary of the plant for the current +// repo. Where the root command draws the ASCII creature, `status` answers the +// practical questions at a glance -- what stage, how long the streak, when the +// last commit landed, and how many days remain before the plant starts to wilt. +// +// Output is deliberately plain and stable (no color, script-friendly) so it can +// be parsed or dropped into other tooling. It shares the read pipeline with the +// root command via runPipeline and, like every command, persists the plant's +// memory afterward unless --no-save is set. +package cmd + +import ( + "errors" + "fmt" + "time" + + "github.com/rwrife/commit-sprout/internal/gitstat" + "github.com/rwrife/commit-sprout/internal/plant" + "github.com/spf13/cobra" +) + +// statusCmd implements `commit-sprout status`. +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show streak, last commit, and days until the plant wilts", + Long: `Print a compact, plain-text summary of the plant for the current repo: +its stage and health, the current streak, when the last commit landed, and how +many days remain before it starts to wilt. + +The output is plain (no color) and stable, so it is easy to read at a glance or +parse from a script.`, + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + return renderStatus(cmd) + }, +} + +// renderStatus runs the shared pipeline and prints the status block. +func renderStatus(cmd *cobra.Command) error { + r, err := runPipeline() + if err != nil { + if errors.Is(err, gitstat.ErrNotARepo) { + return notARepoMessage(cmd) + } + return err + } + + out := cmd.OutOrStdout() + ps := r.plant + + fmt.Fprintf(out, "stage: %s (%s)\n", ps.Stage, ps.Health) + fmt.Fprintf(out, "streak: %s\n", statusStreak(ps.Streak, r.persisted.BestStreak)) + fmt.Fprintf(out, "last: %s\n", statusLastCommit(ps, r.activity.LastCommit, r.now)) + fmt.Fprintf(out, "health: %s\n", statusWilt(ps)) + + persist(r) + return nil +} + +// statusStreak renders the current streak plus the remembered best, so the +// summary carries a little brag energy without needing the full render caption. +func statusStreak(current, best int) string { + cur := streakCount(current) + if best > current { + return fmt.Sprintf("%s (best: %s)", cur, streakCount(best)) + } + return cur +} + +// streakCount pluralizes a day count, matching the render package's wording. +func streakCount(n int) string { + switch { + case n <= 0: + return "none" + case n == 1: + return "1 day" + default: + return fmt.Sprintf("%d days", n) + } +} + +// statusLastCommit phrases the most recent commit in whole days, with the date. +func statusLastCommit(ps plant.PlantState, last time.Time, now time.Time) string { + if ps.DaysSinceCommit < 0 || last.IsZero() { + return "no commits yet \u2014 plant a seed with your first commit" + } + if now.IsZero() { + now = time.Now() + } + stamp := last.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) + } +} + +// statusWilt turns the health state into a one-line, days-until-wilt nudge that +// mirrors the render caption's spirit. Healthy plants report how many idle days +// they can still absorb before turning thirsty. +func statusWilt(ps plant.PlantState) string { + switch ps.Health { + case plant.Wilting: + return "wilting \u2014 commit to revive it" + case plant.Thirsty: + // Thirsty spans days 2-3 idle; wilting begins at day 4. + remaining := 4 - ps.DaysSinceCommit + if remaining < 1 { + remaining = 1 + } + if remaining == 1 { + return "thirsty \u2014 commit today or it starts wilting" + } + return fmt.Sprintf("thirsty \u2014 %d days until it wilts", remaining) + default: + // Healthy: report the cushion before it turns thirsty (day 2 idle). + if ps.DaysSinceCommit < 0 { + return "healthy" + } + cushion := 2 - ps.DaysSinceCommit + if cushion < 1 { + cushion = 1 + } + if cushion == 1 { + return "healthy \u2014 commit today to stay ahead of thirst" + } + return fmt.Sprintf("healthy \u2014 %d days of cushion before it gets thirsty", cushion) + } +} diff --git a/cmd/status_test.go b/cmd/status_test.go new file mode 100644 index 0000000..2bcaa11 --- /dev/null +++ b/cmd/status_test.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "testing" + "time" + + "github.com/rwrife/commit-sprout/internal/plant" +) + +// TestStreakCount covers pluralization and the zero/negative "none" case. +func TestStreakCount(t *testing.T) { + cases := []struct { + in int + want string + }{ + {-3, "none"}, + {0, "none"}, + {1, "1 day"}, + {2, "2 days"}, + {14, "14 days"}, + } + for _, c := range cases { + if got := streakCount(c.in); got != c.want { + t.Errorf("streakCount(%d) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestStatusStreak verifies the "(best: N)" suffix only appears when the +// remembered best actually exceeds the current streak. +func TestStatusStreak(t *testing.T) { + cases := []struct { + name string + current, best int + want string + }{ + {"no best beats current", 6, 6, "6 days"}, + {"best below current", 6, 3, "6 days"}, + {"best above current", 6, 11, "6 days (best: 11 days)"}, + {"fresh plant", 0, 0, "none"}, + {"best from a dead streak", 0, 9, "none (best: 9 days)"}, + } + for _, c := range cases { + if got := statusStreak(c.current, c.best); got != c.want { + t.Errorf("%s: statusStreak(%d,%d) = %q, want %q", + c.name, c.current, c.best, got, c.want) + } + } +} + +// TestStatusLastCommit covers the no-commit sentinel and the today/yesterday/N +// phrasing with a pinned reference time for stability. +func TestStatusLastCommit(t *testing.T) { + now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC) + day := func(d int) time.Time { return now.AddDate(0, 0, -d) } + + cases := []struct { + name string + ps plant.PlantState + last time.Time + want string + }{ + { + name: "no commits", + ps: plant.PlantState{DaysSinceCommit: -1}, + last: time.Time{}, + want: "no commits yet \u2014 plant a seed with your first commit", + }, + { + name: "today", + ps: plant.PlantState{DaysSinceCommit: 0}, + last: day(0), + want: "today (2026-07-04)", + }, + { + name: "yesterday", + ps: plant.PlantState{DaysSinceCommit: 1}, + last: day(1), + want: "yesterday (2026-07-03)", + }, + { + name: "several days", + ps: plant.PlantState{DaysSinceCommit: 5}, + last: day(5), + want: "5 days ago (2026-06-29)", + }, + } + for _, c := range cases { + if got := statusLastCommit(c.ps, c.last, now); got != c.want { + t.Errorf("%s: statusLastCommit = %q, want %q", c.name, got, c.want) + } + } +} + +// TestStatusWilt checks the days-until-wilt nudge across every health state, +// mirroring plant's thresholds (thirsty days 2-3 idle, wilting day 4+). +func TestStatusWilt(t *testing.T) { + cases := []struct { + name string + ps plant.PlantState + want string + }{ + { + name: "no commits is plainly healthy", + ps: plant.PlantState{Health: plant.Healthy, DaysSinceCommit: -1}, + want: "healthy", + }, + { + name: "committed today has two days cushion", + ps: plant.PlantState{Health: plant.Healthy, DaysSinceCommit: 0}, + want: "healthy \u2014 2 days of cushion before it gets thirsty", + }, + { + name: "committed yesterday nudges to commit today", + ps: plant.PlantState{Health: plant.Healthy, DaysSinceCommit: 1}, + want: "healthy \u2014 commit today to stay ahead of thirst", + }, + { + name: "thirsty day 2 has two days until wilt", + ps: plant.PlantState{Health: plant.Thirsty, DaysSinceCommit: 2}, + want: "thirsty \u2014 2 days until it wilts", + }, + { + name: "thirsty day 3 is last-chance", + ps: plant.PlantState{Health: plant.Thirsty, DaysSinceCommit: 3}, + want: "thirsty \u2014 commit today or it starts wilting", + }, + { + name: "wilting asks for water", + ps: plant.PlantState{Health: plant.Wilting, DaysSinceCommit: 6}, + want: "wilting \u2014 commit to revive it", + }, + } + for _, c := range cases { + if got := statusWilt(c.ps); got != c.want { + t.Errorf("%s: statusWilt = %q, want %q", c.name, got, c.want) + } + } +}