crates/terminal-garden/src/lib.rs
236 lines · 7765 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//! 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). Names this
126/// renderer does not draw are 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.
152pub fn render(plots: &[Plot]) -> String {
153 let mut out = String::new();
154 out.push_str("terminal-garden\n");
155 out.push_str("===============\n");
156 if plots.is_empty() {
157 out.push_str(" (no plots yet — waiting for git signals)\n");
158 return out;
159 }
160 for plot in plots {
161 out.push_str(&plot.render_line());
162 out.push('\n');
163 }
164 out
165}
166
167fn short_name(repo: &str) -> String {
168 if repo.is_empty() {
169 return "(repo)".to_string();
170 }
171 repo.rsplit('/').next().unwrap_or(repo).to_string()
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use signal_schema::{Source, Tag, Value, SCHEMA_VERSION};
178
179 #[test]
180 fn more_commits_grow_a_taller_plant() {
181 assert!(Stage::from_commits(0.0) < Stage::from_commits(2.0));
182 assert!(Stage::from_commits(2.0) < Stage::from_commits(5.0));
183 assert!(Stage::from_commits(5.0) < Stage::from_commits(20.0));
184 assert_eq!(Stage::from_commits(0.0), Stage::Seed);
185 assert_eq!(Stage::from_commits(20.0), Stage::Tree);
186 }
187
188 #[test]
189 fn staleness_makes_a_plant_wilt() {
190 assert_eq!(Health::from_days_since(0.0), Health::Fresh);
191 assert!(Health::from_days_since(0.0) < Health::from_days_since(3.0));
192 assert!(Health::from_days_since(3.0) < Health::from_days_since(10.0));
193 assert!(Health::from_days_since(10.0) < Health::from_days_since(40.0));
194 assert_eq!(Health::from_days_since(40.0), Health::Withered);
195 }
196
197 #[test]
198 fn withered_plot_shows_the_brown_glyph_over_its_stage() {
199 let plot = Plot {
200 repo: "/x/big-repo".to_string(),
201 commits_window: 50.0, // would be a Tree
202 commits_today: 0.0,
203 branch_count: 1.0,
204 days_since: 30.0, // but it's withered
205 };
206 assert_eq!(plot.stage(), Stage::Tree);
207 assert_eq!(plot.health(), Health::Withered);
208 assert_eq!(plot.glyph(), "\u{1F342}");
209 }
210
211 #[test]
212 fn plots_group_by_repo_tag() {
213 let mk = |name, value, repo: &str| Signal {
214 schema_version: SCHEMA_VERSION,
215 ts: 1,
216 source: Source::Git,
217 name,
218 value: Value(value),
219 tag: Some(Tag::repo_path(repo).unwrap()),
220 };
221 let signals = vec![
222 mk(SignalName::CommitsWindow, 5.0, "/a/alpha"),
223 mk(SignalName::DaysSinceLastCommit, 0.0, "/a/alpha"),
224 mk(SignalName::CommitsWindow, 0.0, "/b/beta"),
225 mk(SignalName::DaysSinceLastCommit, 20.0, "/b/beta"),
226 ];
227 let plots = plots_from_signals(&signals);
228 assert_eq!(plots.len(), 2);
229 let alpha = plots.iter().find(|p| p.repo == "/a/alpha").unwrap();
230 let beta = plots.iter().find(|p| p.repo == "/b/beta").unwrap();
231 assert_eq!(alpha.stage(), Stage::Sapling);
232 assert_eq!(alpha.health(), Health::Fresh);
233 assert_eq!(beta.stage(), Stage::Seed);
234 assert_eq!(beta.health(), Health::Withered);
235 }
236}