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/pet-life/src/lib.rs

509 lines · 17917 bytes

  1//! # pet-life
  2//!
  3//! The one-life mechanic behind the menu-bar face. A pet ages, neglect kills
  4//! it, and death is permanent for that pet — a new one is born when you come
  5//! back, and the old one is remembered.
  6//!
  7//! ## Everything derives from timestamps
  8//!
  9//! Nothing here accumulates or ticks. Death happens at `last_activity +
 10//! [`LIFESPAN_MS`]`, not when a program noticed, so the answer is identical
 11//! whether you look an hour later or a month later. That makes sleep, reboots,
 12//! daemon restarts and the app simply not running irrelevant by construction
 13//! rather than by handling each case.
 14
 15use std::path::{Path, PathBuf};
 16
 17/// Quiet for this long and the pet is dead.
 18pub const LIFESPAN_MS: u64 = 7 * 86_400_000;
 19
 20/// Stage thresholds: quiet-time at which each begins.
 21const RESTLESS_MS: u64 = 86_400_000;
 22const HUNGRY_MS: u64 = 2 * 86_400_000;
 23const SICK_MS: u64 = 7 * 86_400_000 / 2;
 24const DYING_MS: u64 = 5 * 86_400_000;
 25
 26/// Names for new pets. Deterministic from the birth timestamp: a cemetery of
 27/// "Mochi, 9 days" means something, one of "generation 3" does not.
 28const NAMES: &[&str] = &[
 29    "Mochi", "Bean", "Pip", "Waffle", "Sprout", "Tofu", "Biscuit", "Clover",
 30    "Pickle", "Marble", "Ash", "Juniper", "Olive", "Peanut", "Sage", "Fig",
 31    "Barley", "Cricket", "Dill", "Ember", "Fennel", "Gus", "Hazel", "Indigo",
 32];
 33
 34/// How far a pet has declined.
 35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 36pub enum Stage {
 37    Content,
 38    Restless,
 39    Hungry,
 40    Sick,
 41    Dying,
 42    Dead,
 43}
 44
 45impl Stage {
 46    /// The stage for a pet that has been unattended for `quiet_ms`.
 47    pub fn at(quiet_ms: u64) -> Stage {
 48        match quiet_ms {
 49            q if q >= LIFESPAN_MS => Stage::Dead,
 50            q if q >= DYING_MS => Stage::Dying,
 51            q if q >= SICK_MS => Stage::Sick,
 52            q if q >= HUNGRY_MS => Stage::Hungry,
 53            q if q >= RESTLESS_MS => Stage::Restless,
 54            _ => Stage::Content,
 55        }
 56    }
 57
 58    pub fn name(self) -> &'static str {
 59        match self {
 60            Stage::Content => "content",
 61            Stage::Restless => "restless",
 62            Stage::Hungry => "hungry",
 63            Stage::Sick => "sick",
 64            Stage::Dying => "dying",
 65            Stage::Dead => "dead",
 66        }
 67    }
 68
 69    /// The glyph the menu bar shows.
 70    pub fn face(self) -> &'static str {
 71        match self {
 72            Stage::Content => "(^ω^)",
 73            Stage::Restless => "(・_・)",
 74            Stage::Hungry => "(っ_-)",
 75            Stage::Sick => "(×_×)",
 76            Stage::Dying => "(x_x)",
 77            Stage::Dead => "",
 78        }
 79    }
 80}
 81
 82/// A pet that died, kept forever.
 83#[derive(Debug, Clone, PartialEq)]
 84pub struct Grave {
 85    pub name: String,
 86    pub generation: u32,
 87    pub born_ms: u64,
 88    pub died_ms: u64,
 89}
 90
 91impl Grave {
 92    pub fn lived_days(&self) -> f64 {
 93        self.died_ms.saturating_sub(self.born_ms) as f64 / 86_400_000.0
 94    }
 95}
 96
 97/// A pet currently alive.
 98#[derive(Debug, Clone, PartialEq)]
 99pub struct Pet {
100    pub name: String,
101    pub generation: u32,
102    pub born_ms: u64,
103}
104
105/// Everything persisted between runs.
106#[derive(Debug, Clone, Default, PartialEq)]
107pub struct State {
108    pub pet: Option<Pet>,
109    pub cemetery: Vec<Grave>,
110    /// The latest activity ever observed. The history store only reaches back
111    /// as far as its retention, so this remembers what it has forgotten.
112    pub last_activity_ms: u64,
113}
114
115/// The pet as it stands right now.
116#[derive(Debug, Clone, PartialEq)]
117pub struct Snapshot {
118    pub pet: Option<Pet>,
119    pub stage: Stage,
120    pub quiet_ms: u64,
121    pub age_ms: u64,
122    pub cemetery: Vec<Grave>,
123}
124
125/// Name a pet born at `born_ms`. Deterministic, so the same birth always
126/// produces the same name and a rerun cannot rename the dead.
127pub fn name_for(born_ms: u64, generation: u32) -> String {
128    // Mixing the generation in keeps two pets born in the same millisecond —
129    // only reachable in tests — from sharing a name.
130    let idx = (born_ms / 1000).wrapping_add(generation as u64) as usize % NAMES.len();
131    NAMES[idx].to_string()
132}
133
134/// Advance `state` to `now_ms` given the latest activity, and return what to
135/// display. Pure: the caller decides whether to persist the result.
136///
137/// `observed_activity_ms` is the later of the history store's answer and what
138/// was already persisted, so a stretch with the app closed does not starve a
139/// pet that was being fed the whole time.
140pub fn advance(mut state: State, observed_activity_ms: u64, now_ms: u64) -> (State, Snapshot) {
141    state.last_activity_ms = state.last_activity_ms.max(observed_activity_ms);
142    let last = state.last_activity_ms;
143
144    // A pet that ran out of time is buried at the moment it ran out, not when
145    // a program got around to looking.
146    if let Some(pet) = state.pet.clone() {
147        let quiet = now_ms.saturating_sub(last.max(pet.born_ms));
148        if quiet >= LIFESPAN_MS {
149            let died_ms = last.max(pet.born_ms) + LIFESPAN_MS;
150            state.cemetery.push(Grave {
151                name: pet.name,
152                generation: pet.generation,
153                born_ms: pet.born_ms,
154                died_ms,
155            });
156            state.pet = None;
157        }
158    }
159
160    // Birth needs someone to come back. Being born automatically would turn a
161    // fortnight away into a chain of pets born, never fed and dead — a
162    // cemetery full of lives that did not happen.
163    if state.pet.is_none() {
164        let buried_at = state.cemetery.last().map(|g| g.died_ms).unwrap_or(0);
165        if last > buried_at {
166            let generation = state.cemetery.len() as u32 + 1;
167            state.pet = Some(Pet {
168                name: name_for(last, generation),
169                generation,
170                born_ms: last,
171            });
172        }
173    }
174
175    let snapshot = match &state.pet {
176        Some(pet) => {
177            let quiet = now_ms.saturating_sub(last.max(pet.born_ms));
178            Snapshot {
179                pet: Some(pet.clone()),
180                stage: Stage::at(quiet),
181                quiet_ms: quiet,
182                age_ms: now_ms.saturating_sub(pet.born_ms),
183                cemetery: state.cemetery.clone(),
184            }
185        }
186        None => Snapshot {
187            pet: None,
188            stage: Stage::Dead,
189            quiet_ms: now_ms.saturating_sub(last),
190            age_ms: 0,
191            cemetery: state.cemetery.clone(),
192        },
193    };
194    (state, snapshot)
195}
196
197/// `$XDG_DATA_HOME/ambient-companions/pet.state`, else
198/// `$HOME/.local/share/ambient-companions/pet.state`.
199///
200/// Deliberately not beside the daemon's socket and database. Those derive from
201/// `$XDG_RUNTIME_DIR` when it is set, which is a tmpfs wiped every reboot. A
202/// graveyard a reboot can erase is not a graveyard. Data, not runtime.
203pub fn state_path_from(xdg_data_home: Option<&str>, home: Option<&str>) -> PathBuf {
204    match xdg_data_home {
205        Some(dir) => PathBuf::from(dir).join("ambient-companions/pet.state"),
206        None => PathBuf::from(home.unwrap_or(".")).join(".local/share/ambient-companions/pet.state"),
207    }
208}
209
210pub fn default_state_path() -> PathBuf {
211    state_path_from(
212        std::env::var("XDG_DATA_HOME").ok().as_deref(),
213        std::env::var("HOME").ok().as_deref(),
214    )
215}
216
217/// Write `state` to `path` via a temporary file and a rename, so a crash
218/// part-way through cannot leave a half-written cemetery behind.
219pub fn save(path: &Path, state: &State) -> std::io::Result<()> {
220    if let Some(parent) = path.parent() {
221        std::fs::create_dir_all(parent)?;
222    }
223    let tmp = path.with_extension("state.tmp");
224    std::fs::write(&tmp, serialise(state))?;
225    std::fs::rename(&tmp, path)
226}
227
228/// Read the state, or a fresh one if there is nothing there yet. A file that
229/// cannot be parsed is treated as absent rather than fatal: losing a cemetery
230/// is sad, refusing to draw a menu bar is worse.
231pub fn load(path: &Path) -> State {
232    std::fs::read_to_string(path)
233        .ok()
234        .map(|text| deserialise(&text))
235        .unwrap_or_default()
236}
237
238// --- persistence format ---
239//
240// Line-based rather than JSON. The file is written and read only here, so a
241// hand-rolled JSON *parser* would be all risk and no benefit — the project
242// already stores the terminal spool as plain whitespace-separated records for
243// the same reason. JSON is still what the app is handed; that direction only
244// needs emitting.
245//
246//   last_activity <ms>
247//   pet   <generation> <born_ms> <name>
248//   grave <generation> <born_ms> <died_ms> <name>
249//
250// Names come from a fixed list with no whitespace, so splitting is safe.
251
252/// Render `state` in the on-disk format.
253pub fn serialise(state: &State) -> String {
254    let mut out = format!("last_activity {}\n", state.last_activity_ms);
255    if let Some(p) = &state.pet {
256        out.push_str(&format!("pet {} {} {}\n", p.generation, p.born_ms, p.name));
257    }
258    for g in &state.cemetery {
259        out.push_str(&format!(
260            "grave {} {} {} {}\n",
261            g.generation, g.born_ms, g.died_ms, g.name
262        ));
263    }
264    out
265}
266
267/// Parse the on-disk format. Unreadable lines are skipped rather than fatal:
268/// losing part of a cemetery is sad, refusing to draw a menu bar is worse.
269pub fn deserialise(text: &str) -> State {
270    let mut state = State::default();
271    for line in text.lines() {
272        let f: Vec<&str> = line.split_whitespace().collect();
273        match f.as_slice() {
274            ["last_activity", ms] => {
275                state.last_activity_ms = ms.parse().unwrap_or(0);
276            }
277            ["pet", gen, born, name] => {
278                if let (Ok(generation), Ok(born_ms)) = (gen.parse(), born.parse()) {
279                    state.pet = Some(Pet {
280                        name: (*name).to_string(),
281                        generation,
282                        born_ms,
283                    });
284                }
285            }
286            ["grave", gen, born, died, name] => {
287                if let (Ok(generation), Ok(born_ms), Ok(died_ms)) =
288                    (gen.parse(), born.parse(), died.parse())
289                {
290                    state.cemetery.push(Grave {
291                        name: (*name).to_string(),
292                        generation,
293                        born_ms,
294                        died_ms,
295                    });
296                }
297            }
298            _ => {}
299        }
300    }
301    state
302}
303
304/// The contract handed to the menu-bar app. Emitting only — nothing reads this
305/// back in Rust.
306///
307/// Every value is a number, a boolean, a stage name, or a pet name from a
308/// fixed list, so there is no input needing escaping and no reason to take on
309/// a serialisation dependency for it.
310pub fn to_json(s: &Snapshot) -> String {
311    let days = |ms: u64| ms as f64 / 86_400_000.0;
312    let graves: Vec<String> = s
313        .cemetery
314        .iter()
315        .map(|g| {
316            format!(
317                r#"{{"name":"{}","generation":{},"lived_days":{:.2}}}"#,
318                g.name,
319                g.generation,
320                g.lived_days()
321            )
322        })
323        .collect();
324    let (name, generation) = match &s.pet {
325        Some(p) => (format!(r#""{}""#, p.name), p.generation.to_string()),
326        None => ("null".to_string(), "null".to_string()),
327    };
328    format!(
329        r#"{{"alive":{},"name":{},"generation":{},"stage":"{}","face":"{}","age_days":{:.2},"quiet_days":{:.2},"cemetery":[{}]}}"#,
330        s.pet.is_some(),
331        name,
332        generation,
333        s.stage.name(),
334        s.stage.face(),
335        days(s.age_ms),
336        days(s.quiet_ms),
337        graves.join(",")
338    )
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    const DAY: u64 = 86_400_000;
346    /// An arbitrary but fixed "now", so nothing here depends on the clock.
347    const T0: u64 = 1_700_000_000_000;
348
349    /// A pet born at T0 that has just been fed.
350    fn living() -> State {
351        let (state, _) = advance(State::default(), T0, T0);
352        state
353    }
354
355    #[test]
356    fn stages_step_through_the_week() {
357        for (quiet, expected) in [
358            (0, Stage::Content),
359            (DAY - 1, Stage::Content),
360            (DAY, Stage::Restless),
361            (2 * DAY, Stage::Hungry),
362            (7 * DAY / 2, Stage::Sick),
363            (5 * DAY, Stage::Dying),
364            (7 * DAY - 1, Stage::Dying),
365            (7 * DAY, Stage::Dead),
366            (90 * DAY, Stage::Dead),
367        ] {
368            assert_eq!(Stage::at(quiet), expected, "quiet for {quiet}ms");
369        }
370    }
371
372    #[test]
373    fn activity_resets_the_decline() {
374        let state = living();
375        let (state, snap) = advance(state, T0, T0 + 3 * DAY);
376        assert_eq!(snap.stage, Stage::Hungry);
377        let (_, snap) = advance(state, T0 + 3 * DAY, T0 + 3 * DAY);
378        assert_eq!(snap.stage, Stage::Content, "showing up restores it");
379    }
380
381    /// Death is derived, so when you look does not change what happened.
382    #[test]
383    fn death_is_recorded_at_the_moment_it_ran_out() {
384        let (state, _) = advance(living(), T0, T0 + 30 * DAY);
385        assert_eq!(state.cemetery.len(), 1);
386        assert_eq!(
387            state.cemetery[0].died_ms,
388            T0 + 7 * DAY,
389            "died when the week ran out, not when observed"
390        );
391        assert_eq!(state.cemetery[0].lived_days(), 7.0);
392    }
393
394    /// The reason birth needs activity: otherwise a fortnight away breeds a
395    /// chain of pets that were born, never fed, and died.
396    #[test]
397    fn a_long_absence_leaves_one_grave_not_a_chain() {
398        let (state, snap) = advance(living(), T0, T0 + 60 * DAY);
399        assert_eq!(state.cemetery.len(), 1, "one death, not eight");
400        assert!(state.pet.is_none(), "nothing is born while you are away");
401        assert_eq!(snap.stage, Stage::Dead);
402    }
403
404    #[test]
405    fn coming_back_starts_a_new_pet() {
406        let (state, _) = advance(living(), T0, T0 + 30 * DAY);
407        let returned = T0 + 30 * DAY;
408        let (state, snap) = advance(state, returned, returned);
409
410        let pet = snap.pet.expect("a new pet");
411        assert_eq!(pet.generation, 2);
412        assert_eq!(snap.stage, Stage::Content);
413        assert_eq!(state.cemetery.len(), 1, "the first is still remembered");
414        assert_ne!(state.cemetery[0].name, pet.name, "and it is a different pet");
415    }
416
417    #[test]
418    fn the_cemetery_accumulates_across_generations() {
419        let mut state = living();
420        let mut t = T0;
421        for expected in 1..=3 {
422            t += 30 * DAY;
423            let (s, _) = advance(state, t - 30 * DAY, t); // die
424            let (s, _) = advance(s, t, t); // and be replaced
425            state = s;
426            assert_eq!(state.cemetery.len(), expected);
427            assert_eq!(state.pet.as_ref().unwrap().generation, expected as u32 + 1);
428        }
429    }
430
431    /// The history store only reaches back as far as its retention, so a
432    /// remembered timestamp has to win over an older one.
433    #[test]
434    fn persisted_activity_outranks_a_forgotten_history() {
435        let state = State {
436            last_activity_ms: T0 + 5 * DAY,
437            ..living()
438        };
439        // History has been pruned and answers with something older.
440        let (state, snap) = advance(state, T0, T0 + 5 * DAY);
441        assert_eq!(state.last_activity_ms, T0 + 5 * DAY);
442        assert_eq!(snap.stage, Stage::Content, "not starved by a pruned table");
443    }
444
445    #[test]
446    fn a_name_is_stable_for_a_given_birth() {
447        assert_eq!(name_for(T0, 1), name_for(T0, 1));
448        assert_ne!(name_for(T0, 1), name_for(T0 + 60_000, 1));
449    }
450
451    #[test]
452    fn state_round_trips_through_the_file_format() {
453        let (state, _) = advance(living(), T0, T0 + 30 * DAY);
454        let (state, _) = advance(state, T0 + 30 * DAY, T0 + 30 * DAY);
455        assert_eq!(deserialise(&serialise(&state)), state);
456    }
457
458    #[test]
459    fn a_damaged_file_is_treated_as_empty_rather_than_fatal() {
460        let s = deserialise("last_activity notanumber\ngrave oops\n\x00garbage");
461        assert_eq!(s, State::default());
462    }
463
464    #[test]
465    fn saving_is_atomic_and_leaves_no_temporary_behind() {
466        let dir = std::env::temp_dir().join(format!("pet-life-save-{}", std::process::id()));
467        let _ = std::fs::remove_dir_all(&dir);
468        let path = dir.join("pet.state");
469        let (state, _) = advance(living(), T0, T0 + 30 * DAY);
470
471        save(&path, &state).expect("save");
472        assert_eq!(load(&path), state);
473        assert!(!path.with_extension("state.tmp").exists(), "no temp left over");
474        let _ = std::fs::remove_dir_all(&dir);
475    }
476
477    #[test]
478    fn state_path_is_data_not_runtime() {
479        assert_eq!(
480            state_path_from(None, Some("/Users/x")),
481            PathBuf::from("/Users/x/.local/share/ambient-companions/pet.state"),
482        );
483        assert_eq!(
484            state_path_from(Some("/Users/x/.local/share"), Some("/Users/x")),
485            PathBuf::from("/Users/x/.local/share/ambient-companions/pet.state"),
486        );
487    }
488
489    /// The contract the app parses, pinned to exact bytes — the discipline
490    /// that has already caught two cross-language mismatches here.
491    #[test]
492    fn the_json_contract_is_exact() {
493        let (state, _) = advance(living(), T0, T0 + 30 * DAY);
494        let (_, snap) = advance(state, T0 + 30 * DAY, T0 + 30 * DAY + DAY / 2);
495        assert_eq!(
496            to_json(&snap),
497            r#"{"alive":true,"name":"Ash","generation":2,"stage":"content","face":"(^ω^)","age_days":0.50,"quiet_days":0.50,"cemetery":[{"name":"Marble","generation":1,"lived_days":7.00}]}"#
498        );
499    }
500
501    #[test]
502    fn the_json_contract_handles_a_dead_pet() {
503        let (_, snap) = advance(living(), T0, T0 + 30 * DAY);
504        let json = to_json(&snap);
505        assert!(json.contains(r#""alive":false"#));
506        assert!(json.contains(r#""name":null"#));
507        assert!(json.contains(r#""stage":"dead""#));
508    }
509}