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

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}