crates/terminal-pet/src/lib.rs
355 lines · 12444 bytes
1//! # terminal-pet
2//!
3//! The second face: a pet whose mood follows the shell and the machine.
4//!
5//! The garden renders the four git aggregates. Everything else on the bus —
6//! the shell's keystroke rate and session length, the five hardware scalars —
7//! had no reader at all, which meant the collectors producing them could stop
8//! without anything visibly changing. This is their reader.
9//!
10//! It is also where the daemon's own health becomes visible.
11//! [`SignalName::CollectorUp`] at `0.0` makes the pet sick, and that outranks
12//! every other mood: a pet that cannot feel its own hardware should look wrong
13//! rather than look calm.
14//!
15//! Stateless by design — [`PetState`] is a pure function of the current
16//! snapshot, so there is no persistence to corrupt and nothing to migrate.
17
18use std::collections::BTreeMap;
19
20use signal_schema::{Signal, SignalName, Source};
21
22/// A signal older than this is treated as stale: the collector may still be up
23/// while the value behind it stopped moving. Comfortably past the daemon's 2s
24/// default tick, so a healthy system never flickers.
25pub const STALE_AFTER_MS: u64 = 30_000;
26
27/// Keystrokes per minute at or above which the pet reads as busy.
28pub const BUSY_KEYS_PER_MIN: f64 = 60.0;
29/// `ProcessInfo` thermal state at or above which the pet reads as overheating.
30pub const HOT_THERMAL_STATE: f64 = 2.0;
31/// Battery percentage below which an unplugged machine reads as flagging.
32pub const LOW_BATTERY_PCT: f64 = 20.0;
33
34/// One metric as the pet sees it.
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct Reading {
37 pub value: f64,
38 /// The value has not been refreshed within [`STALE_AFTER_MS`]. Rendered
39 /// dimmed: "up but stalled" is not the same as "down".
40 pub stale: bool,
41}
42
43/// What the pet is doing, in precedence order.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Mood {
46 /// A collector is down. Outranks everything else.
47 Sick,
48 Overheating,
49 LowBattery,
50 Busy,
51 Calm,
52 Sleeping,
53}
54
55impl Mood {
56 pub fn face(self) -> &'static str {
57 match self {
58 Mood::Sick => "(x_x)",
59 Mood::Overheating => "(>_<)",
60 Mood::LowBattery => "(-_-)",
61 Mood::Busy => "(o_o)",
62 Mood::Calm => "(^_^)",
63 Mood::Sleeping => "(-.-)",
64 }
65 }
66
67 pub fn label(self) -> &'static str {
68 match self {
69 Mood::Sick => "sick",
70 Mood::Overheating => "overheating",
71 Mood::LowBattery => "flagging",
72 Mood::Busy => "busy",
73 Mood::Calm => "calm",
74 Mood::Sleeping => "asleep",
75 }
76 }
77}
78
79/// The pet, derived from one snapshot.
80#[derive(Debug, Clone, PartialEq)]
81pub struct PetState {
82 pub mood: Mood,
83 /// Collectors reporting down, in wire order.
84 pub down: Vec<Source>,
85 pub keys_per_min: Option<Reading>,
86 pub session_seconds: Option<Reading>,
87 pub cpu_load: Option<Reading>,
88 pub thermal_state: Option<Reading>,
89 pub battery_pct: Option<Reading>,
90 pub charging: Option<Reading>,
91 pub battery_draw_w: Option<Reading>,
92}
93
94/// Fold a snapshot into the pet.
95///
96/// `now_ms` is passed rather than read so the staleness rule is testable.
97pub fn pet_from_signals(signals: &[Signal], now_ms: u64) -> PetState {
98 let mut latest: BTreeMap<(u8, u8), &Signal> = BTreeMap::new();
99 for s in signals {
100 latest.insert((s.name.to_u8(), s.source.to_u8()), s);
101 }
102
103 let read = |name: SignalName| -> Option<Reading> {
104 latest
105 .iter()
106 .find(|((n, _), _)| *n == name.to_u8())
107 .map(|(_, s)| Reading {
108 value: s.value.0,
109 stale: now_ms.saturating_sub(s.ts) > STALE_AFTER_MS,
110 })
111 };
112
113 let mut down: Vec<Source> = latest
114 .iter()
115 .filter(|((n, _), s)| *n == SignalName::CollectorUp.to_u8() && s.value.0 == 0.0)
116 .filter_map(|((_, src), _)| Source::from_u8(*src))
117 .collect();
118 down.sort_by_key(|s| s.to_u8());
119
120 let keys_per_min = read(SignalName::KeysPerMin);
121 let thermal_state = read(SignalName::ThermalState);
122 let battery_pct = read(SignalName::BatteryPct);
123 let charging = read(SignalName::Charging);
124
125 // Mood is driven only by readings that are still current. A cached value
126 // outlives what it described: the terminal collector stops publishing once
127 // a session falls outside its active window, so the last keystroke rate
128 // sits in the daemon's cache indefinitely and would otherwise leave the pet
129 // looking busy long after the typing stopped.
130 let fresh = |r: Option<Reading>| r.filter(|r| !r.stale).map(|r| r.value);
131
132 let plugged_in = fresh(charging).map(|c| c >= 1.0).unwrap_or(false);
133 let mood = if !down.is_empty() {
134 Mood::Sick
135 } else if fresh(thermal_state).map(|t| t >= HOT_THERMAL_STATE).unwrap_or(false) {
136 Mood::Overheating
137 } else if fresh(battery_pct).map(|b| b < LOW_BATTERY_PCT).unwrap_or(false) && !plugged_in {
138 Mood::LowBattery
139 } else {
140 match fresh(keys_per_min) {
141 Some(k) if k >= BUSY_KEYS_PER_MIN => Mood::Busy,
142 Some(k) if k > 0.0 => Mood::Calm,
143 _ => Mood::Sleeping,
144 }
145 };
146
147 PetState {
148 mood,
149 down,
150 keys_per_min,
151 session_seconds: read(SignalName::SessionSeconds),
152 cpu_load: read(SignalName::CpuLoad),
153 thermal_state,
154 battery_pct,
155 charging,
156 battery_draw_w: read(SignalName::BatteryDrawW),
157 }
158}
159
160/// Render the pet as a text block.
161pub fn render(state: &PetState) -> String {
162 let mut out = String::from("terminal-pet\n============\n\n");
163 out.push_str(&format!(" {} {}\n\n", state.mood.face(), state.mood.label()));
164
165 let row = |label: &str, reading: Option<Reading>, unit: &str| -> String {
166 match reading {
167 None => format!(" {label:<16} —\n"),
168 Some(r) => {
169 let mark = if r.stale { " (stale)" } else { "" };
170 format!(" {label:<16} {:.2}{unit}{mark}\n", r.value)
171 }
172 }
173 };
174 out.push_str(&row("keys/min", state.keys_per_min, ""));
175 out.push_str(&row("session", state.session_seconds, "s"));
176 out.push_str(&row("cpu load", state.cpu_load, ""));
177 out.push_str(&row("thermal", state.thermal_state, ""));
178 out.push_str(&row("battery", state.battery_pct, "%"));
179 out.push_str(&row("draw", state.battery_draw_w, "W"));
180
181 if !state.down.is_empty() {
182 out.push_str("\n collectors down:");
183 for source in &state.down {
184 out.push_str(&format!(" {source:?}"));
185 }
186 out.push('\n');
187 }
188 out
189}
190
191/// Render the pet as a single line for a shell prompt: the face, and the
192/// keystroke rate when the terminal collector has one.
193///
194/// Deliberately short and fixed-ish in width — it shares a prompt with
195/// everything else, and a segment that changes width every keystroke moves the
196/// rest of the prompt around.
197pub fn oneline(state: &PetState) -> String {
198 match state.keys_per_min {
199 Some(k) if !k.stale => format!("{} {:.0}k/m", state.mood.face(), k.value),
200 // A stale or absent rate is not worth a number: it would read as
201 // current and be minutes old.
202 _ => state.mood.face().to_string(),
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use signal_schema::{Value, SCHEMA_VERSION};
210
211 fn sig(source: Source, name: SignalName, value: f64, ts: u64) -> Signal {
212 Signal {
213 schema_version: SCHEMA_VERSION,
214 ts,
215 source,
216 name,
217 value: Value(value),
218 tag: None,
219 }
220 }
221
222 const NOW: u64 = 1_000_000;
223
224 fn typing(keys: f64) -> Signal {
225 sig(Source::Terminal, SignalName::KeysPerMin, keys, NOW)
226 }
227
228 #[test]
229 fn oneline_is_the_face_and_the_rate() {
230 let pet = pet_from_signals(&[typing(42.4)], NOW);
231 assert_eq!(oneline(&pet), "(^_^) 42k/m");
232 }
233
234 #[test]
235 fn oneline_drops_a_rate_it_cannot_vouch_for() {
236 let old = sig(Source::Terminal, SignalName::KeysPerMin, 99.0, NOW - STALE_AFTER_MS - 1);
237 assert_eq!(oneline(&pet_from_signals(&[old], NOW)), "(-.-)", "stale rate omitted");
238 assert_eq!(oneline(&pet_from_signals(&[], NOW)), "(-.-)", "no rate at all");
239 }
240
241 #[test]
242 fn oneline_shows_a_sick_face() {
243 let signals = [
244 typing(10.0),
245 sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW),
246 ];
247 assert_eq!(oneline(&pet_from_signals(&signals, NOW)), "(x_x) 10k/m");
248 }
249
250 #[test]
251 fn typing_drives_energy() {
252 assert_eq!(pet_from_signals(&[typing(0.0)], NOW).mood, Mood::Sleeping);
253 assert_eq!(pet_from_signals(&[typing(10.0)], NOW).mood, Mood::Calm);
254 assert_eq!(pet_from_signals(&[typing(120.0)], NOW).mood, Mood::Busy);
255 }
256
257 #[test]
258 fn no_terminal_signal_at_all_is_asleep_not_sick() {
259 assert_eq!(pet_from_signals(&[], NOW).mood, Mood::Sleeping);
260 }
261
262 #[test]
263 fn heat_outranks_typing() {
264 let signals = [
265 typing(120.0),
266 sig(Source::Macos, SignalName::ThermalState, 3.0, NOW),
267 ];
268 assert_eq!(pet_from_signals(&signals, NOW).mood, Mood::Overheating);
269 }
270
271 #[test]
272 fn a_low_battery_only_counts_when_unplugged() {
273 let low = sig(Source::Macos, SignalName::BatteryPct, 5.0, NOW);
274 let plugged = sig(Source::Macos, SignalName::Charging, 1.0, NOW);
275 let unplugged = sig(Source::Macos, SignalName::Charging, 0.0, NOW);
276
277 assert_eq!(
278 pet_from_signals(&[typing(1.0), low.clone(), unplugged], NOW).mood,
279 Mood::LowBattery
280 );
281 assert_eq!(
282 pet_from_signals(&[typing(1.0), low, plugged], NOW).mood,
283 Mood::Calm,
284 "on the charger a low battery is not a worry"
285 );
286 }
287
288 /// The point of putting health on the bus: a dead collector is visible in
289 /// the face, not just in a log nobody reads.
290 #[test]
291 fn a_down_collector_outranks_every_other_mood() {
292 let signals = [
293 typing(120.0),
294 sig(Source::Macos, SignalName::ThermalState, 3.0, NOW),
295 sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW),
296 ];
297 let pet = pet_from_signals(&signals, NOW);
298 assert_eq!(pet.mood, Mood::Sick);
299 assert_eq!(pet.down, vec![Source::Hardware]);
300 assert!(render(&pet).contains("collectors down: Hardware"));
301 }
302
303 #[test]
304 fn a_healthy_collector_is_not_listed_as_down() {
305 let signals = [
306 typing(5.0),
307 sig(Source::Hardware, SignalName::CollectorUp, 1.0, NOW),
308 ];
309 let pet = pet_from_signals(&signals, NOW);
310 assert_eq!(pet.mood, Mood::Calm);
311 assert!(pet.down.is_empty());
312 }
313
314 #[test]
315 fn health_is_tracked_per_source() {
316 let signals = [
317 sig(Source::Git, SignalName::CollectorUp, 1.0, NOW),
318 sig(Source::Terminal, SignalName::CollectorUp, 0.0, NOW),
319 sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW),
320 ];
321 let pet = pet_from_signals(&signals, NOW);
322 assert_eq!(pet.down, vec![Source::Terminal, Source::Hardware]);
323 }
324
325 /// The terminal collector stops publishing once a session falls outside
326 /// its active window, so the last rate sits in the cache. The pet must not
327 /// keep looking busy on the strength of it.
328 #[test]
329 fn a_stale_rate_does_not_keep_the_pet_busy() {
330 let old = sig(
331 Source::Terminal,
332 SignalName::KeysPerMin,
333 300.0,
334 NOW - STALE_AFTER_MS - 1,
335 );
336 assert_eq!(pet_from_signals(&[old], NOW).mood, Mood::Sleeping);
337 }
338
339 /// Up but stalled is not the same as down: the collector never reported a
340 /// failure, the number behind it simply stopped moving.
341 #[test]
342 fn an_old_value_is_stale_but_not_sick() {
343 let old = sig(
344 Source::Hardware,
345 SignalName::CpuLoad,
346 0.5,
347 NOW - STALE_AFTER_MS - 1,
348 );
349 let pet = pet_from_signals(&[typing(5.0), old], NOW);
350 assert!(pet.cpu_load.expect("cpu load").stale);
351 assert!(!pet.keys_per_min.expect("keys").stale);
352 assert_eq!(pet.mood, Mood::Calm, "stale is not sick");
353 assert!(render(&pet).contains("(stale)"));
354 }
355}