diff --git a/README.md b/README.md index 0c90065..66b1eeb 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,11 @@ tagged release. Watch the repo. window. You can peek at it today with the hidden `commit-sprout --activity` debug flag.)_ 2. Map that activity → a growth **stage** (seed → sprout → leafy → tall → blooming) plus a **health** modifier (wilting when you've gone quiet). + _(Implemented — M3. The state machine lives in `internal/plant`: `plant.Compute(activity, + state, now)` is a pure, deterministic function that resolves the stage (streak-driven, with a + busy-day shortcut), a health modifier (healthy → thirsty → wilting by commit recency), and a + short mood line. Remembered peak growth floors the stage so one quiet day dents health before + it shrinks the plant. Thresholds all live in one tunable block.)_ 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. diff --git a/internal/plant/plant.go b/internal/plant/plant.go index fa81df0..5ea50b0 100644 --- a/internal/plant/plant.go +++ b/internal/plant/plant.go @@ -3,8 +3,323 @@ // the most heavily unit-tested module in the project. // // Stages progress seed -> sprout -> leafy -> tall -> blooming, with a health -// modifier that wilts the plant when commits have gone quiet. +// modifier (healthy / thirsty / wilting) that degrades the plant when commits +// have gone quiet. The whole package is pure: Compute takes an Activity, a +// remembered State, and a reference clock, and returns a deterministic +// PlantState. Same inputs always yield the same output, so it can be exercised +// entirely with table-driven tests and no live repository. // -// Implementation lands in M3 (Plant state machine). This stub exists so the -// package directory and intent are established during M1 scaffolding. +// All tunable numbers live together in the "Tuning thresholds" block below so +// the plant's personality can be adjusted in exactly one place. package plant + +import ( + "time" + + "github.com/rwrife/commit-sprout/internal/gitstat" +) + +// --------------------------------------------------------------------------- +// Tuning thresholds +// +// Everything that decides how the plant grows and wilts is gathered here so the +// behavior is easy to reason about and tune. Nothing else in the package should +// hard-code a magic number. +// --------------------------------------------------------------------------- + +const ( + // Growth is driven primarily by the current streak: consistent daily + // commits are what make the plant climb through its stages. These are + // the minimum streak lengths (in consecutive days) required to *reach* + // each stage. + // + // streak >= 1 -> Sprout (you showed up today/yesterday) + // streak >= 3 -> Leafy + // streak >= 7 -> Tall + // streak >= 14 -> Blooming + // + // A streak of 0 with no commits at all is Seed. + sproutStreak = 1 + leafyStreak = 3 + tallStreak = 7 + bloomingStreak = 14 + + // busyDayCommits is a "productive burst" shortcut: a lot of commits in a + // single window, even without a long streak, is enough to nudge a bare + // seed up to a sprout. It keeps the plant from feeling dead on the very + // first productive day before any streak has accumulated. + busyDayCommits = 3 + + // Health is driven by how long it has been since the last commit, + // measured in whole calendar days in the reference timezone. + // + // <= thirstyAfterDays -> Healthy + // <= wiltingAfterDays -> Thirsty + // > wiltingAfterDays -> Wilting + // + // "Today" is 0 days idle, "yesterday" is 1, and so on. + thirstyAfterDays = 1 // still healthy through yesterday + wiltingAfterDays = 3 // thirsty on days 2-3, wilting from day 4+ +) + +// Stage is the plant's growth stage. Stages are ordered; higher values are +// more grown. The zero value is Seed. +type Stage int + +const ( + // Seed is a plant with no activity yet -- nothing has sprouted. + Seed Stage = iota + // Sprout is the first green: a recent commit or a fresh streak. + Sprout + // Leafy is a plant with a few consecutive days of commits. + Leafy + // Tall is a well-established streak. + Tall + // Blooming is the reward stage for a long, consistent streak. + Blooming +) + +// String returns a lowercase, stable name for the stage. These names are part +// of the CLI/prompt contract (they can surface in output), so keep them stable. +func (s Stage) String() string { + switch s { + case Seed: + return "seed" + case Sprout: + return "sprout" + case Leafy: + return "leafy" + case Tall: + return "tall" + case Blooming: + return "blooming" + default: + return "unknown" + } +} + +// Health is the plant's condition modifier, driven by commit recency. The zero +// value is Healthy. +type Health int + +const ( + // Healthy: committed recently (today or yesterday). + Healthy Health = iota + // Thirsty: a short dry spell -- a gentle nudge, not yet wilting. + Thirsty + // Wilting: no commits for several days; the plant is visibly drooping. + Wilting +) + +// String returns a lowercase, stable name for the health state. +func (h Health) String() string { + switch h { + case Healthy: + return "healthy" + case Thirsty: + return "thirsty" + case Wilting: + return "wilting" + default: + return "unknown" + } +} + +// State is the remembered, persisted memory of the plant between runs. It is +// deliberately small and lives here (rather than in the store package) so that +// plant.Compute has no dependency on I/O and can be tested in isolation. The +// store package (M5) is responsible for loading/saving a value that maps onto +// this shape. +// +// The zero value is a brand-new plant that has never grown: HighestStage Seed, +// no best streak. +type State struct { + // HighestStage is the tallest stage the plant has ever reached. It acts + // as a soft floor so a single missed day doesn't collapse a mature plant + // straight back to a seed; see Compute for exactly how it is applied. + HighestStage Stage + + // BestStreak is the longest streak ever achieved. It is carried for + // display/brag purposes and does not currently affect the computed + // stage, but lives here so persistence is forward-compatible. + BestStreak int +} + +// PlantState is the fully-resolved state of the plant for one render: its +// current growth Stage, its Health modifier, and a short Mood line with +// personality. It is a pure function of (Activity, State, now) and carries no +// behavior of its own. +type PlantState struct { + // Stage is the growth stage to render. + Stage Stage + + // Health is the condition modifier to render. + Health Health + + // Streak is the current streak, surfaced for convenience (mirrors + // Activity.Streak) so renderers/status output don't need the raw + // Activity. + Streak int + + // DaysSinceCommit is the number of whole calendar days since the last + // commit, in the reference timezone. It is 0 when a commit landed today + // and -1 when there are no commits at all (nothing to measure from). + DaysSinceCommit int + + // Mood is a short, flavorful one-liner describing how the plant "feels" + // given its stage and health. Kept terse and with a little personality. + Mood string + + // UpdatedHighestStage is the highest stage the plant has now reached, + // i.e. max(State.HighestStage, computed live stage). Callers that + // persist state (M5) should store this back so growth is remembered. + UpdatedHighestStage Stage +} + +// Compute maps activity plus remembered state into a PlantState, as of the +// reference time now. It is pure and deterministic: identical inputs always +// produce an identical PlantState. +// +// Growth comes from the live streak (with a small "busy day" shortcut), then is +// floored by the highest stage ever reached so a mature plant degrades in +// *health* first rather than instantly regressing to a seed on one quiet day. +// A prolonged silence (Wilting) does allow the visible stage to slip one step +// below its remembered peak, so neglect eventually shows in growth too -- but +// never below Sprout once anything has ever grown. +func Compute(act gitstat.Activity, st State, now time.Time) PlantState { + days := daysSinceCommit(act, now) + health := healthFor(days, act.HasCommits) + live := liveStage(act) + + // Remember the tallest we've ever been (peak of memory and live growth). + highest := st.HighestStage + if live > highest { + highest = live + } + + // Resolve the stage to render. Start from the live stage, then apply the + // remembered floor so we don't yo-yo on a single missed day. + stage := live + if highest > stage { + stage = flooredStage(highest, health) + } + + return PlantState{ + Stage: stage, + Health: health, + Streak: act.Streak, + DaysSinceCommit: days, + Mood: moodFor(stage, health, act), + UpdatedHighestStage: highest, + } +} + +// liveStage computes the stage implied purely by current activity, ignoring any +// remembered peak. Growth is streak-driven, with a busy-day shortcut so the +// first productive day already sprouts something. +func liveStage(act gitstat.Activity) Stage { + switch { + case act.Streak >= bloomingStreak: + return Blooming + case act.Streak >= tallStreak: + return Tall + case act.Streak >= leafyStreak: + return Leafy + case act.Streak >= sproutStreak: + return Sprout + case act.HasCommits && act.TotalInWindow >= busyDayCommits: + // A burst of commits with no established streak still counts as a + // sprout -- you clearly did something. + return Sprout + default: + return Seed + } +} + +// flooredStage decides how much of a remembered peak stage the plant keeps when +// its live growth has fallen behind. While Healthy or Thirsty the plant holds +// its remembered peak (a couple of quiet days shouldn't visibly shrink a mature +// plant). Once Wilting, it slips exactly one stage below its peak to make +// prolonged neglect visible -- but never below Sprout, so a plant that has ever +// grown never fully reverts to a bare seed. +func flooredStage(highest Stage, health Health) Stage { + if health != Wilting { + return highest + } + slipped := highest - 1 + if slipped < Sprout { + slipped = Sprout + } + return slipped +} + +// daysSinceCommit returns whole calendar days between the last commit and now, +// in now's timezone. It returns -1 when there are no commits (nothing to +// measure from). A commit earlier today yields 0, yesterday 1, and so on. +func daysSinceCommit(act gitstat.Activity, now time.Time) int { + if !act.HasCommits || act.LastCommit.IsZero() { + return -1 + } + loc := now.Location() + last := dayStart(act.LastCommit.In(loc)) + today := dayStart(now) + d := int(today.Sub(last).Hours() / 24) + if d < 0 { + // A commit timestamped slightly in the future (clock skew) is + // treated as "today" rather than a negative age. + d = 0 + } + return d +} + +// dayStart truncates a time to midnight in its own location, giving a stable +// calendar-day anchor for day-difference math. +func dayStart(t time.Time) time.Time { + y, m, d := t.Date() + return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) +} + +// healthFor maps days-since-last-commit to a Health. With no commits at all the +// plant is a fresh Seed and reported Healthy (there is nothing to wilt yet). +func healthFor(days int, hasCommits bool) Health { + if !hasCommits || days < 0 { + return Healthy + } + switch { + case days <= thirstyAfterDays: + return Healthy + case days <= wiltingAfterDays: + return Thirsty + default: + return Wilting + } +} + +// moodFor returns a short flavor line for the given stage/health, with a little +// personality. Health takes priority for the "problem" states (thirsty/wilting) +// so the message nudges you to commit; otherwise the message celebrates the +// current growth stage. +func moodFor(stage Stage, health Health, act gitstat.Activity) string { + switch health { + case Wilting: + return "Parched and drooping. A commit today would really help." + case Thirsty: + return "Getting a little dry -- a commit soon keeps it perky." + } + + // Healthy: celebrate the stage. + switch stage { + case Seed: + return "Just a seed in the soil. Commit something to make it sprout." + case Sprout: + return "A fresh little sprout. Keep the streak going!" + case Leafy: + return "Leafing out nicely -- a few solid days in a row." + case Tall: + return "Standing tall on a healthy streak. Impressive cadence." + case Blooming: + return "In full bloom! That is a serious commit streak. 🌸" + default: + return "Growing along." + } +} diff --git a/internal/plant/plant_test.go b/internal/plant/plant_test.go new file mode 100644 index 0000000..68514a4 --- /dev/null +++ b/internal/plant/plant_test.go @@ -0,0 +1,282 @@ +package plant + +import ( + "testing" + "time" + + "github.com/rwrife/commit-sprout/internal/gitstat" +) + +// fixedNow is a stable reference clock. UTC keeps the day-boundary math in the +// fixtures obvious. +var fixedNow = time.Date(2026, time.June, 30, 12, 0, 0, 0, time.UTC) + +// commitDaysAgo returns a timestamp N whole days before fixedNow (still at +// noon, so it's unambiguously that calendar day). +func commitDaysAgo(n int) time.Time { + return fixedNow.AddDate(0, 0, -n) +} + +// act builds an Activity with the fields the state machine actually reads. +// hasCommits is derived from whether a last-commit day is provided. +func act(streak, total, lastDaysAgo int, hasCommits bool) gitstat.Activity { + a := gitstat.Activity{ + Author: "me@example.com", + WindowDays: 7, + HasCommits: hasCommits, + TotalInWindow: total, + Streak: streak, + } + if hasCommits { + a.LastCommit = commitDaysAgo(lastDaysAgo) + } + return a +} + +func TestComputeStageByStreak(t *testing.T) { + cases := []struct { + name string + streak int + total int + want Stage + }{ + {"no streak, no commits -> seed", 0, 0, Seed}, + {"one day -> sprout", 1, 1, Sprout}, + {"two days -> still sprout", 2, 2, Sprout}, + {"three days -> leafy", 3, 3, Leafy}, + {"six days -> leafy", 6, 6, Leafy}, + {"seven days -> tall", 7, 7, Tall}, + {"thirteen days -> tall", 13, 20, Tall}, + {"fourteen days -> blooming", 14, 30, Blooming}, + {"long streak -> blooming", 40, 60, Blooming}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Commit today so health is Healthy and doesn't interfere + // with the pure stage-by-streak check. + a := act(tc.streak, tc.total, 0, tc.streak > 0 || tc.total > 0) + got := Compute(a, State{}, fixedNow) + if got.Stage != tc.want { + t.Errorf("Stage = %v; want %v", got.Stage, tc.want) + } + }) + } +} + +func TestBusyDayShortcutSprouts(t *testing.T) { + // No streak yet (streak 0) but a burst of commits today should still + // count as a sprout rather than a dead seed. + a := act(0, busyDayCommits, 0, true) + got := Compute(a, State{}, fixedNow) + if got.Stage != Sprout { + t.Errorf("busy day: Stage = %v; want Sprout", got.Stage) + } + + // Just under the busy-day threshold with no streak stays a seed. + a2 := act(0, busyDayCommits-1, 0, true) + got2 := Compute(a2, State{}, fixedNow) + if got2.Stage != Seed { + t.Errorf("below busy-day threshold: Stage = %v; want Seed", got2.Stage) + } +} + +func TestHealthByRecency(t *testing.T) { + cases := []struct { + name string + lastDays int + want Health + }{ + {"today -> healthy", 0, Healthy}, + {"yesterday -> healthy", 1, Healthy}, + {"two days -> thirsty", 2, Thirsty}, + {"three days -> thirsty", 3, Thirsty}, + {"four days -> wilting", 4, Wilting}, + {"ten days -> wilting", 10, Wilting}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Give it an established streak so it's a real plant; the + // streak value itself doesn't affect health. + a := act(7, 10, tc.lastDays, true) + got := Compute(a, State{}, fixedNow) + if got.Health != tc.want { + t.Errorf("Health = %v; want %v (days=%d)", got.Health, tc.want, tc.lastDays) + } + if got.DaysSinceCommit != tc.lastDays { + t.Errorf("DaysSinceCommit = %d; want %d", got.DaysSinceCommit, tc.lastDays) + } + }) + } +} + +func TestNoCommitsIsHealthySeed(t *testing.T) { + a := act(0, 0, 0, false) + got := Compute(a, State{}, fixedNow) + if got.Stage != Seed { + t.Errorf("Stage = %v; want Seed", got.Stage) + } + if got.Health != Healthy { + t.Errorf("Health = %v; want Healthy (nothing to wilt yet)", got.Health) + } + if got.DaysSinceCommit != -1 { + t.Errorf("DaysSinceCommit = %d; want -1 for no commits", got.DaysSinceCommit) + } +} + +func TestMemoryFloorHoldsWhileHealthy(t *testing.T) { + // The plant once reached Tall. Today activity is weak (short streak -> + // live Sprout) but the last commit was yesterday, so it's Healthy. A + // mature plant shouldn't collapse to a sprout on one slow day: it holds + // its remembered peak. + a := act(1, 1, 1, true) + st := State{HighestStage: Tall} + got := Compute(a, st, fixedNow) + if got.Stage != Tall { + t.Errorf("Stage = %v; want Tall (held by memory while healthy)", got.Stage) + } + if got.UpdatedHighestStage != Tall { + t.Errorf("UpdatedHighestStage = %v; want Tall", got.UpdatedHighestStage) + } +} + +func TestMemoryFloorSlipsOneStageWhenWilting(t *testing.T) { + // Once wilting (4+ idle days), a remembered Tall plant slips exactly one + // stage to Leafy -- neglect becomes visible, but it doesn't crater. + a := act(0, 0, 4, true) + st := State{HighestStage: Tall} + got := Compute(a, st, fixedNow) + if got.Health != Wilting { + t.Fatalf("precondition: Health = %v; want Wilting", got.Health) + } + if got.Stage != Leafy { + t.Errorf("Stage = %v; want Leafy (Tall slipped one while wilting)", got.Stage) + } + if got.UpdatedHighestStage != Tall { + t.Errorf("UpdatedHighestStage = %v; want Tall (peak remembered)", got.UpdatedHighestStage) + } +} + +func TestWiltingNeverFallsBelowSprout(t *testing.T) { + // A remembered Sprout that is now wilting must not drop to Seed: anything + // that ever grew keeps at least a sprout. + a := act(0, 0, 9, true) + st := State{HighestStage: Sprout} + got := Compute(a, st, fixedNow) + if got.Stage != Sprout { + t.Errorf("Stage = %v; want Sprout (floor while wilting)", got.Stage) + } +} + +func TestLiveGrowthBeatsStaleMemory(t *testing.T) { + // Memory says Sprout, but today's activity is a booming 14-day streak. + // Live growth should win and the peak should update to Blooming. + a := act(14, 30, 0, true) + st := State{HighestStage: Sprout} + got := Compute(a, st, fixedNow) + if got.Stage != Blooming { + t.Errorf("Stage = %v; want Blooming (live growth exceeds memory)", got.Stage) + } + if got.UpdatedHighestStage != Blooming { + t.Errorf("UpdatedHighestStage = %v; want Blooming", got.UpdatedHighestStage) + } +} + +func TestMoodPrioritizesHealthProblems(t *testing.T) { + // Wilting mood should mention dryness regardless of stage. + a := act(0, 0, 5, true) + got := Compute(a, State{HighestStage: Blooming}, fixedNow) + if got.Health != Wilting { + t.Fatalf("precondition Health = %v; want Wilting", got.Health) + } + if got.Mood == "" { + t.Fatal("Mood is empty") + } + if !containsAny(got.Mood, "Parched", "drooping", "dry") { + t.Errorf("wilting Mood = %q; expected dryness language", got.Mood) + } +} + +func TestMoodCelebratesBloom(t *testing.T) { + a := act(20, 40, 0, true) + got := Compute(a, State{}, fixedNow) + if got.Stage != Blooming || got.Health != Healthy { + t.Fatalf("precondition stage=%v health=%v; want Blooming/Healthy", got.Stage, got.Health) + } + if !containsAny(got.Mood, "bloom", "Bloom") { + t.Errorf("blooming Mood = %q; expected bloom language", got.Mood) + } +} + +func TestDeterministic(t *testing.T) { + a := act(5, 8, 2, true) + st := State{HighestStage: Leafy, BestStreak: 9} + first := Compute(a, st, fixedNow) + for i := 0; i < 25; i++ { + got := Compute(a, st, fixedNow) + if got != first { + t.Fatalf("Compute not deterministic: run %d = %+v, first = %+v", i, got, first) + } + } +} + +func TestClockSkewFutureCommitIsToday(t *testing.T) { + // A commit timestamped slightly in the future (clock skew) should be + // treated as today (0 days), not a negative age. + a := gitstat.Activity{ + HasCommits: true, + TotalInWindow: 1, + Streak: 1, + LastCommit: fixedNow.Add(2 * time.Hour), + } + got := Compute(a, State{}, fixedNow) + if got.DaysSinceCommit != 0 { + t.Errorf("DaysSinceCommit = %d; want 0 for slight future skew", got.DaysSinceCommit) + } + if got.Health != Healthy { + t.Errorf("Health = %v; want Healthy", got.Health) + } +} + +func TestStageAndHealthStringsStable(t *testing.T) { + stages := map[Stage]string{ + Seed: "seed", Sprout: "sprout", Leafy: "leafy", + Tall: "tall", Blooming: "blooming", + } + for s, want := range stages { + if got := s.String(); got != want { + t.Errorf("Stage(%d).String() = %q; want %q", s, got, want) + } + } + healths := map[Health]string{ + Healthy: "healthy", Thirsty: "thirsty", Wilting: "wilting", + } + for h, want := range healths { + if got := h.String(); got != want { + t.Errorf("Health(%d).String() = %q; want %q", h, got, want) + } + } +} + +// containsAny reports whether s contains any of the given substrings. +func containsAny(s string, subs ...string) bool { + for _, sub := range subs { + if len(sub) > 0 && indexOf(s, sub) >= 0 { + return true + } + } + return false +} + +// indexOf is a tiny substring search to avoid importing strings just for this. +func indexOf(s, sub string) int { + n, m := len(s), len(sub) + if m == 0 { + return 0 + } + for i := 0; i+m <= n; i++ { + if s[i:i+m] == sub { + return i + } + } + return -1 +}