crates/terminal-garden/src/lib.rs
237 lines · 7865 bytes
1//! # terminal-garden (library)
2//!
3//! The pure garden model: signals in, a rendered garden out. Kept as a library
4//! so the mapping is unit-testable independently of the socket subscriber in
5//! `src/main.rs`.
6//!
7//! Each watched repo is a plot; growth stage is a function of recent commit
8//! activity and health (wilt) is a function of days-since-last-commit
9//! (spec §2.2). A plant advances 🌱→🌿→🌳 as commits land, and droops then
10//! browns as a repo goes stale.
11
12use std::collections::BTreeMap;
13
14use signal_schema::{Signal, SignalName};
15
16/// Growth stage, driven by recent commit volume. `Ord` is meaningful: more
17/// commits never yields a lower stage.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
19pub enum Stage {
20 Seed,
21 Sprout,
22 Sapling,
23 Tree,
24}
25
26impl Stage {
27 /// Stage from commits within the collector window (spec: growth tracks
28 /// cumulative recent commit activity).
29 pub fn from_commits(commits_window: f64) -> Stage {
30 match commits_window as u64 {
31 0 => Stage::Seed,
32 1..=2 => Stage::Sprout,
33 3..=6 => Stage::Sapling,
34 _ => Stage::Tree,
35 }
36 }
37
38 fn glyph(self) -> &'static str {
39 match self {
40 Stage::Seed => ".",
41 Stage::Sprout => "\u{1F331}", // 🌱
42 Stage::Sapling => "\u{1F33F}", // 🌿
43 Stage::Tree => "\u{1F333}", // 🌳
44 }
45 }
46}
47
48/// Health (wilt), driven by staleness. `Ord` is meaningful: more days stale
49/// never yields a healthier state.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
51pub enum Health {
52 Fresh,
53 Dry,
54 Wilting,
55 Withered,
56}
57
58impl Health {
59 /// Health from whole days since the last commit.
60 pub fn from_days_since(days_since: f64) -> Health {
61 match days_since as u64 {
62 0..=1 => Health::Fresh,
63 2..=6 => Health::Dry,
64 7..=13 => Health::Wilting,
65 _ => Health::Withered,
66 }
67 }
68
69 fn label(self) -> &'static str {
70 match self {
71 Health::Fresh => "fresh",
72 Health::Dry => "dry",
73 Health::Wilting => "wilting",
74 Health::Withered => "withered",
75 }
76 }
77}
78
79/// One repo's rendered plot.
80#[derive(Debug, Clone, PartialEq)]
81pub struct Plot {
82 pub repo: String,
83 pub commits_window: f64,
84 pub commits_today: f64,
85 pub branch_count: f64,
86 pub days_since: f64,
87}
88
89impl Plot {
90 pub fn stage(&self) -> Stage {
91 Stage::from_commits(self.commits_window)
92 }
93
94 pub fn health(&self) -> Health {
95 Health::from_days_since(self.days_since)
96 }
97
98 /// The glyph shown for the plant: a withered plot browns regardless of how
99 /// tall it once grew; a wilting one droops; otherwise it shows its stage.
100 pub fn glyph(&self) -> &'static str {
101 match self.health() {
102 Health::Withered => "\u{1F342}", // 🍂
103 Health::Wilting => "\u{1F940}", // 🥀
104 _ => self.stage().glyph(),
105 }
106 }
107
108 fn render_line(&self) -> String {
109 format!(
110 " {glyph} {repo:<24} stage={stage:<7} health={health:<8} \
111 (commits: {win} in window, {today} today | branches: {branches} | last commit {days}d ago)",
112 glyph = self.glyph(),
113 repo = short_name(&self.repo),
114 stage = format!("{:?}", self.stage()).to_lowercase(),
115 health = self.health().label(),
116 win = self.commits_window as u64,
117 today = self.commits_today as u64,
118 branches = self.branch_count as u64,
119 days = self.days_since as u64,
120 )
121 }
122}
123
124/// Fold a snapshot of signals into one plot per repo (keyed by the audited repo
125/// tag; untagged signals collapse into a single unnamed plot). Later git names
126/// (`Commits5m`, diff-line counts, dirty-worktree) are simply ignored here.
127pub fn plots_from_signals(signals: &[Signal]) -> Vec<Plot> {
128 let mut by_repo: BTreeMap<String, Plot> = BTreeMap::new();
129
130 for s in signals {
131 let key = s.tag.as_ref().map(|t| t.as_str().to_string()).unwrap_or_default();
132 let plot = by_repo.entry(key.clone()).or_insert_with(|| Plot {
133 repo: key.clone(),
134 commits_window: 0.0,
135 commits_today: 0.0,
136 branch_count: 0.0,
137 days_since: 0.0,
138 });
139 match s.name {
140 SignalName::CommitsWindow => plot.commits_window = s.value.0,
141 SignalName::CommitsToday => plot.commits_today = s.value.0,
142 SignalName::BranchCount => plot.branch_count = s.value.0,
143 SignalName::DaysSinceLastCommit => plot.days_since = s.value.0,
144 _ => {}
145 }
146 }
147
148 by_repo.into_values().collect()
149}
150
151/// Render the whole garden to a text block (spec §2.2: a text render is fine for
152/// v0.1).
153pub fn render(plots: &[Plot]) -> String {
154 let mut out = String::new();
155 out.push_str("terminal-garden\n");
156 out.push_str("===============\n");
157 if plots.is_empty() {
158 out.push_str(" (no plots yet — waiting for git signals)\n");
159 return out;
160 }
161 for plot in plots {
162 out.push_str(&plot.render_line());
163 out.push('\n');
164 }
165 out
166}
167
168fn short_name(repo: &str) -> String {
169 if repo.is_empty() {
170 return "(repo)".to_string();
171 }
172 repo.rsplit('/').next().unwrap_or(repo).to_string()
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use signal_schema::{Source, Tag, Value, SCHEMA_VERSION};
179
180 #[test]
181 fn more_commits_grow_a_taller_plant() {
182 assert!(Stage::from_commits(0.0) < Stage::from_commits(2.0));
183 assert!(Stage::from_commits(2.0) < Stage::from_commits(5.0));
184 assert!(Stage::from_commits(5.0) < Stage::from_commits(20.0));
185 assert_eq!(Stage::from_commits(0.0), Stage::Seed);
186 assert_eq!(Stage::from_commits(20.0), Stage::Tree);
187 }
188
189 #[test]
190 fn staleness_makes_a_plant_wilt() {
191 assert_eq!(Health::from_days_since(0.0), Health::Fresh);
192 assert!(Health::from_days_since(0.0) < Health::from_days_since(3.0));
193 assert!(Health::from_days_since(3.0) < Health::from_days_since(10.0));
194 assert!(Health::from_days_since(10.0) < Health::from_days_since(40.0));
195 assert_eq!(Health::from_days_since(40.0), Health::Withered);
196 }
197
198 #[test]
199 fn withered_plot_shows_the_brown_glyph_over_its_stage() {
200 let plot = Plot {
201 repo: "/x/big-repo".to_string(),
202 commits_window: 50.0, // would be a Tree
203 commits_today: 0.0,
204 branch_count: 1.0,
205 days_since: 30.0, // but it's withered
206 };
207 assert_eq!(plot.stage(), Stage::Tree);
208 assert_eq!(plot.health(), Health::Withered);
209 assert_eq!(plot.glyph(), "\u{1F342}");
210 }
211
212 #[test]
213 fn plots_group_by_repo_tag() {
214 let mk = |name, value, repo: &str| Signal {
215 schema_version: SCHEMA_VERSION,
216 ts: 1,
217 source: Source::Git,
218 name,
219 value: Value(value),
220 tag: Some(Tag::repo_path(repo).unwrap()),
221 };
222 let signals = vec![
223 mk(SignalName::CommitsWindow, 5.0, "/a/alpha"),
224 mk(SignalName::DaysSinceLastCommit, 0.0, "/a/alpha"),
225 mk(SignalName::CommitsWindow, 0.0, "/b/beta"),
226 mk(SignalName::DaysSinceLastCommit, 20.0, "/b/beta"),
227 ];
228 let plots = plots_from_signals(&signals);
229 assert_eq!(plots.len(), 2);
230 let alpha = plots.iter().find(|p| p.repo == "/a/alpha").unwrap();
231 let beta = plots.iter().find(|p| p.repo == "/b/beta").unwrap();
232 assert_eq!(alpha.stage(), Stage::Sapling);
233 assert_eq!(alpha.health(), Health::Fresh);
234 assert_eq!(beta.stage(), Stage::Seed);
235 assert_eq!(beta.health(), Health::Withered);
236 }
237}