Ambient system companions over one privacy-preserving signal daemon (aggregate-only, no keystroke content): a git-driven terminal garden and IOKit hardware collectors. ambient daemon macos privacy terminal

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}