//! # terminal-garden (library) //! //! The pure garden model: signals in, a rendered garden out. Kept as a library //! so the mapping is unit-testable independently of the socket subscriber in //! `src/main.rs`. //! //! Each watched repo is a plot; growth stage is a function of recent commit //! activity and health (wilt) is a function of days-since-last-commit //! A plant advances 🌱→🌿→🌳 as commits land, and droops then //! browns as a repo goes stale. use std::collections::BTreeMap; use signal_schema::{Signal, SignalName}; /// Growth stage, driven by recent commit volume. `Ord` is meaningful: more /// commits never yields a lower stage. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Stage { Seed, Sprout, Sapling, Tree, } impl Stage { /// Stage from commits within the collector window (spec: growth tracks /// cumulative recent commit activity). pub fn from_commits(commits_window: f64) -> Stage { match commits_window as u64 { 0 => Stage::Seed, 1..=2 => Stage::Sprout, 3..=6 => Stage::Sapling, _ => Stage::Tree, } } fn glyph(self) -> &'static str { match self { Stage::Seed => ".", Stage::Sprout => "\u{1F331}", // 🌱 Stage::Sapling => "\u{1F33F}", // 🌿 Stage::Tree => "\u{1F333}", // 🌳 } } } /// Health (wilt), driven by staleness. `Ord` is meaningful: more days stale /// never yields a healthier state. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Health { Fresh, Dry, Wilting, Withered, } impl Health { /// Health from whole days since the last commit. pub fn from_days_since(days_since: f64) -> Health { match days_since as u64 { 0..=1 => Health::Fresh, 2..=6 => Health::Dry, 7..=13 => Health::Wilting, _ => Health::Withered, } } fn label(self) -> &'static str { match self { Health::Fresh => "fresh", Health::Dry => "dry", Health::Wilting => "wilting", Health::Withered => "withered", } } } /// One repo's rendered plot. #[derive(Debug, Clone, PartialEq)] pub struct Plot { pub repo: String, pub commits_window: f64, pub commits_today: f64, pub branch_count: f64, pub days_since: f64, } impl Plot { pub fn stage(&self) -> Stage { Stage::from_commits(self.commits_window) } pub fn health(&self) -> Health { Health::from_days_since(self.days_since) } /// The glyph shown for the plant: a withered plot browns regardless of how /// tall it once grew; a wilting one droops; otherwise it shows its stage. pub fn glyph(&self) -> &'static str { match self.health() { Health::Withered => "\u{1F342}", // 🍂 Health::Wilting => "\u{1F940}", // 🥀 _ => self.stage().glyph(), } } fn render_line(&self) -> String { format!( " {glyph} {repo:<24} stage={stage:<7} health={health:<8} \ (commits: {win} in window, {today} today | branches: {branches} | last commit {days}d ago)", glyph = self.glyph(), repo = short_name(&self.repo), stage = format!("{:?}", self.stage()).to_lowercase(), health = self.health().label(), win = self.commits_window as u64, today = self.commits_today as u64, branches = self.branch_count as u64, days = self.days_since as u64, ) } } /// Fold a snapshot of signals into one plot per repo (keyed by the audited repo /// tag; untagged signals collapse into a single unnamed plot). Names this /// renderer does not draw are ignored here. pub fn plots_from_signals(signals: &[Signal]) -> Vec { let mut by_repo: BTreeMap = BTreeMap::new(); for s in signals { let key = s.tag.as_ref().map(|t| t.as_str().to_string()).unwrap_or_default(); let plot = by_repo.entry(key.clone()).or_insert_with(|| Plot { repo: key.clone(), commits_window: 0.0, commits_today: 0.0, branch_count: 0.0, days_since: 0.0, }); match s.name { SignalName::CommitsWindow => plot.commits_window = s.value.0, SignalName::CommitsToday => plot.commits_today = s.value.0, SignalName::BranchCount => plot.branch_count = s.value.0, SignalName::DaysSinceLastCommit => plot.days_since = s.value.0, _ => {} } } by_repo.into_values().collect() } /// Render the whole garden to a text block. pub fn render(plots: &[Plot]) -> String { let mut out = String::new(); out.push_str("terminal-garden\n"); out.push_str("===============\n"); if plots.is_empty() { out.push_str(" (no plots yet — waiting for git signals)\n"); return out; } for plot in plots { out.push_str(&plot.render_line()); out.push('\n'); } out } fn short_name(repo: &str) -> String { if repo.is_empty() { return "(repo)".to_string(); } repo.rsplit('/').next().unwrap_or(repo).to_string() } #[cfg(test)] mod tests { use super::*; use signal_schema::{Source, Tag, Value, SCHEMA_VERSION}; #[test] fn more_commits_grow_a_taller_plant() { assert!(Stage::from_commits(0.0) < Stage::from_commits(2.0)); assert!(Stage::from_commits(2.0) < Stage::from_commits(5.0)); assert!(Stage::from_commits(5.0) < Stage::from_commits(20.0)); assert_eq!(Stage::from_commits(0.0), Stage::Seed); assert_eq!(Stage::from_commits(20.0), Stage::Tree); } #[test] fn staleness_makes_a_plant_wilt() { assert_eq!(Health::from_days_since(0.0), Health::Fresh); assert!(Health::from_days_since(0.0) < Health::from_days_since(3.0)); assert!(Health::from_days_since(3.0) < Health::from_days_since(10.0)); assert!(Health::from_days_since(10.0) < Health::from_days_since(40.0)); assert_eq!(Health::from_days_since(40.0), Health::Withered); } #[test] fn withered_plot_shows_the_brown_glyph_over_its_stage() { let plot = Plot { repo: "/x/big-repo".to_string(), commits_window: 50.0, // would be a Tree commits_today: 0.0, branch_count: 1.0, days_since: 30.0, // but it's withered }; assert_eq!(plot.stage(), Stage::Tree); assert_eq!(plot.health(), Health::Withered); assert_eq!(plot.glyph(), "\u{1F342}"); } #[test] fn plots_group_by_repo_tag() { let mk = |name, value, repo: &str| Signal { schema_version: SCHEMA_VERSION, ts: 1, source: Source::Git, name, value: Value(value), tag: Some(Tag::repo_path(repo).unwrap()), }; let signals = vec![ mk(SignalName::CommitsWindow, 5.0, "/a/alpha"), mk(SignalName::DaysSinceLastCommit, 0.0, "/a/alpha"), mk(SignalName::CommitsWindow, 0.0, "/b/beta"), mk(SignalName::DaysSinceLastCommit, 20.0, "/b/beta"), ]; let plots = plots_from_signals(&signals); assert_eq!(plots.len(), 2); let alpha = plots.iter().find(|p| p.repo == "/a/alpha").unwrap(); let beta = plots.iter().find(|p| p.repo == "/b/beta").unwrap(); assert_eq!(alpha.stage(), Stage::Sapling); assert_eq!(alpha.health(), Health::Fresh); assert_eq!(beta.stage(), Stage::Seed); assert_eq!(beta.health(), Health::Withered); } }