1use std::io;
13use std::path::PathBuf;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use log::debug;
17use serde::{Deserialize, Serialize};
18
19use crate::fs_util;
20
21const RETENTION_DAYS: u64 = 90;
26
27const SECS_PER_DAY: u64 = 86_400;
28
29pub const DEMO_NOW_SECS: u64 = 1_778_932_800; #[cfg(test)]
36thread_local! {
37 static PATH_OVERRIDE: std::cell::RefCell<Option<PathBuf>> =
38 const { std::cell::RefCell::new(None) };
39}
40
41#[cfg(test)]
44pub fn set_path_override(path: PathBuf) {
45 PATH_OVERRIDE.with(|p| *p.borrow_mut() = Some(path));
46}
47
48fn activity_path() -> Option<PathBuf> {
49 #[cfg(test)]
50 {
51 PATH_OVERRIDE.with(|p| p.borrow().clone())
52 }
53 #[cfg(not(test))]
54 {
55 dirs::home_dir().map(|h| h.join(".purple/key_activity.json"))
56 }
57}
58
59pub fn now_secs() -> u64 {
63 SystemTime::now()
64 .duration_since(UNIX_EPOCH)
65 .map(|d| d.as_secs())
66 .unwrap_or(0)
67}
68
69pub fn now_for_render() -> u64 {
75 if crate::demo_flag::is_demo() {
76 DEMO_NOW_SECS
77 } else {
78 now_secs()
79 }
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
83pub struct ConnectEvent {
84 pub alias: String,
85 pub ts: u64,
87}
88
89#[derive(Debug, Clone, Default, Serialize, Deserialize)]
90pub struct KeyActivityLog {
91 pub events: Vec<ConnectEvent>,
92}
93
94impl KeyActivityLog {
95 pub fn load() -> Self {
100 let Some(path) = activity_path() else {
101 return Self::default();
102 };
103 match std::fs::read_to_string(&path) {
104 Ok(s) => match serde_json::from_str::<Self>(&s) {
105 Ok(mut log) => {
106 log.prune(now_secs());
107 log
108 }
109 Err(e) => {
110 let backup = path.with_extension(format!("json.corrupt-{}", now_secs()));
111 if let Err(rename_err) = std::fs::rename(&path, &backup) {
112 debug!(
113 "[purple] key_activity: parse failed and could not preserve corrupt file: parse={e} rename={rename_err}",
114 );
115 } else {
116 debug!(
117 "[purple] key_activity: parse failed, preserved corrupt file at {}: {e}",
118 backup.display(),
119 );
120 }
121 Self::default()
122 }
123 },
124 Err(e) => {
125 if e.kind() != io::ErrorKind::NotFound {
126 debug!("[purple] key_activity: read failed: {e}");
127 }
128 Self::default()
129 }
130 }
131 }
132
133 pub fn record(&mut self, alias: &str, now: u64) {
138 self.events.push(ConnectEvent {
139 alias: alias.to_string(),
140 ts: now,
141 });
142 self.prune(now);
143 }
144
145 fn prune(&mut self, now: u64) {
146 let cutoff = now.saturating_sub(RETENTION_DAYS * SECS_PER_DAY);
147 self.events.retain(|e| e.ts >= cutoff);
148 }
149
150 pub fn flush(&self) -> io::Result<()> {
157 if crate::demo_flag::is_demo() {
158 debug!(
159 "[purple] key_activity: demo mode, skipping disk flush ({} events held in memory)",
160 self.events.len(),
161 );
162 return Ok(());
163 }
164 let Some(path) = activity_path() else {
165 return Ok(());
166 };
167 let body = serde_json::to_vec_pretty(self)
168 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
169 fs_util::atomic_write(&path, &body)
170 }
171
172 pub fn record_oneshot(alias: &str, now: u64) {
176 let mut log = Self::load();
177 log.record(alias, now);
178 if let Err(e) = log.flush() {
179 debug!("[purple] key_activity: flush failed: {e}");
180 }
181 }
182
183 pub fn last_use_for_aliases(&self, aliases: &[String]) -> Option<u64> {
185 let lookup = alias_set(aliases);
186 self.events
187 .iter()
188 .filter(|e| lookup.contains(e.alias.as_str()))
189 .map(|e| e.ts)
190 .max()
191 }
192
193 pub fn timestamps_for_aliases(&self, aliases: &[String]) -> Vec<u64> {
197 let lookup = alias_set(aliases);
198 self.events
199 .iter()
200 .filter(|e| lookup.contains(e.alias.as_str()))
201 .map(|e| e.ts)
202 .collect()
203 }
204}
205
206pub fn record_and_flush(log: &mut KeyActivityLog, alias: &str, now: u64) {
213 log.record(alias, now);
214 if let Err(e) = log.flush() {
215 debug!("[purple] key_activity: flush failed: {e}");
216 }
217}
218
219fn alias_set(aliases: &[String]) -> std::collections::HashSet<&str> {
222 aliases.iter().map(String::as_str).collect()
223}
224
225pub fn humanize_last_use(now: u64, ts: u64) -> String {
230 let diff = now.saturating_sub(ts);
231 if diff < 60 {
232 return "just now".to_string();
233 }
234 let minutes = diff / 60;
235 if minutes < 60 {
236 return format!("{minutes}m ago");
237 }
238 let hours = minutes / 60;
239 if hours < 24 {
240 return format!("{hours}h ago");
241 }
242 let days = hours / 24;
243 if days < 7 {
244 return format!("{days}d ago");
245 }
246 let weeks = days / 7;
247 if weeks < 5 {
248 return format!("{weeks}w ago");
249 }
250 let months = days / 30;
251 if months < 12 {
252 return format!("{months}mo ago");
253 }
254 let years = days / 365;
255 format!("{years}y ago")
256}
257
258pub fn format_created(now: u64, mtime_ts: u64) -> String {
262 let date = format_yyyy_mm_dd(mtime_ts);
263 let age = humanize_last_use(now, mtime_ts);
264 format!("{date} ({age})")
265}
266
267fn format_yyyy_mm_dd(ts: u64) -> String {
270 let days_since_epoch = (ts / SECS_PER_DAY) as i64;
271 let (y, m, d) = civil_from_days(days_since_epoch);
272 format!("{:04}-{:02}-{:02}", y, m, d)
273}
274
275fn civil_from_days(z: i64) -> (i32, u32, u32) {
279 let z = z + 719_468;
280 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
281 let doe = (z - era * 146_097) as u64;
282 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
283 let y = yoe as i64 + era * 400;
284 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
285 let mp = (5 * doy + 2) / 153;
286 let d = doy - (153 * mp + 2) / 5 + 1;
287 let m = if mp < 10 { mp + 3 } else { mp - 9 };
288 let y = y + if m <= 2 { 1 } else { 0 };
289 (y as i32, m as u32, d as u32)
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 fn setup() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
303 let guard = crate::demo_flag::GLOBAL_TEST_LOCK
304 .lock()
305 .unwrap_or_else(|p| p.into_inner());
306 let dir = tempfile::tempdir().expect("tempdir");
307 set_path_override(dir.path().join("key_activity.json"));
308 (dir, guard)
309 }
310
311 #[test]
312 fn record_appends_event() {
313 let (_g, _lock) = setup();
314 let mut log = KeyActivityLog::default();
315 log.record("prod-eu1", now_secs());
316 assert_eq!(log.events.len(), 1);
317 assert_eq!(log.events[0].alias, "prod-eu1");
318 }
319
320 #[test]
321 fn record_prunes_events_past_retention() {
322 let (_g, _lock) = setup();
323 let mut log = KeyActivityLog::default();
324 let now = now_secs();
325 let very_old = now - (RETENTION_DAYS + 10) * SECS_PER_DAY;
326 log.events.push(ConnectEvent {
327 alias: "ancient".into(),
328 ts: very_old,
329 });
330 log.record("fresh", now);
331 assert_eq!(log.events.len(), 1);
332 assert_eq!(log.events[0].alias, "fresh");
333 }
334
335 #[test]
336 fn load_after_flush_roundtrips() {
337 let (_g, _lock) = setup();
338 let mut log = KeyActivityLog::default();
339 let now = now_secs();
340 log.record("eric-bastion", now);
341 log.record("aws-api-prod", now);
342 log.flush().unwrap();
343 let reloaded = KeyActivityLog::load();
344 assert_eq!(reloaded.events.len(), 2);
345 }
346
347 #[test]
348 fn load_missing_file_returns_default() {
349 let (_g, _lock) = setup();
350 let log = KeyActivityLog::load();
351 assert!(log.events.is_empty());
352 }
353
354 #[test]
355 fn last_use_returns_most_recent() {
356 let (_g, _lock) = setup();
357 let mut log = KeyActivityLog::default();
358 log.events.push(ConnectEvent {
359 alias: "h".into(),
360 ts: 100,
361 });
362 log.events.push(ConnectEvent {
363 alias: "h".into(),
364 ts: 500,
365 });
366 log.events.push(ConnectEvent {
367 alias: "h".into(),
368 ts: 300,
369 });
370 let aliases = vec!["h".to_string()];
371 assert_eq!(log.last_use_for_aliases(&aliases), Some(500));
372 }
373
374 #[test]
375 fn last_use_none_for_no_matches() {
376 let (_g, _lock) = setup();
377 let log = KeyActivityLog::default();
378 let aliases = vec!["nobody".to_string()];
379 assert!(log.last_use_for_aliases(&aliases).is_none());
380 }
381
382 #[test]
383 fn humanize_last_use_buckets() {
384 assert_eq!(humanize_last_use(1000, 999), "just now");
385 assert_eq!(humanize_last_use(1000, 600), "6m ago");
386 assert_eq!(humanize_last_use(SECS_PER_DAY * 2, 0), "2d ago");
387 assert_eq!(humanize_last_use(SECS_PER_DAY * 14, 0), "2w ago");
388 assert_eq!(humanize_last_use(SECS_PER_DAY * 60, 0), "2mo ago");
389 assert_eq!(humanize_last_use(SECS_PER_DAY * 400, 0), "1y ago");
390 }
391
392 #[test]
393 fn record_oneshot_persists_to_disk() {
394 let (_g, _lock) = setup();
395 KeyActivityLog::record_oneshot("h1", now_secs());
396 let reloaded = KeyActivityLog::load();
397 assert_eq!(reloaded.events.len(), 1);
398 assert_eq!(reloaded.events[0].alias, "h1");
399 }
400
401 #[test]
402 fn civil_from_days_known_dates() {
403 assert_eq!(civil_from_days(0), (1970, 1, 1));
405 assert_eq!(civil_from_days(19794), (2024, 3, 12));
407 assert_eq!(civil_from_days(20589), (2026, 5, 16));
409 }
410
411 #[test]
412 fn format_yyyy_mm_dd_known() {
413 assert_eq!(format_yyyy_mm_dd(1_778_932_800), "2026-05-16");
415 assert_eq!(format_yyyy_mm_dd(1_710_244_800), "2024-03-12");
417 }
418
419 #[test]
420 fn format_created_combines_date_and_age() {
421 let now = 1_778_932_800;
422 let created = 1_710_244_800; let out = format_created(now, created);
424 assert!(out.starts_with("2024-03-12 ("));
425 assert!(out.ends_with(" ago)"));
426 }
427
428 #[test]
431 fn humanize_boundary_60s_is_1m_not_just_now() {
432 assert_eq!(humanize_last_use(1000, 940), "1m ago");
433 }
434
435 #[test]
436 fn humanize_boundary_exactly_1h() {
437 assert_eq!(humanize_last_use(3600, 0), "1h ago");
438 }
439
440 #[test]
441 fn humanize_boundary_exactly_7d() {
442 assert_eq!(humanize_last_use(SECS_PER_DAY * 7, 0), "1w ago");
443 }
444
445 #[test]
446 fn humanize_boundary_35d_falls_to_months() {
447 assert_eq!(humanize_last_use(SECS_PER_DAY * 35, 0), "1mo ago");
450 }
451
452 #[test]
453 fn prune_keeps_event_at_exactly_retention_boundary() {
454 let (_g, _lock) = setup();
455 let now = 200 * SECS_PER_DAY;
456 let mut log = KeyActivityLog::default();
457 log.events.push(ConnectEvent {
458 alias: "edge".into(),
459 ts: now - RETENTION_DAYS * SECS_PER_DAY,
460 });
461 log.prune(now);
462 assert_eq!(log.events.len(), 1);
463 }
464
465 #[test]
466 fn civil_from_days_leap_day_2000() {
467 assert_eq!(civil_from_days(11016), (2000, 2, 29));
469 }
470
471 #[test]
472 fn load_corrupt_json_returns_empty_log() {
473 let (g, _lock) = setup();
474 let path = g.path().join("key_activity.json");
475 std::fs::write(&path, b"not valid json {{").unwrap();
476 let log = KeyActivityLog::load();
477 assert!(log.events.is_empty());
478 }
479
480 #[test]
481 fn load_corrupt_json_preserves_file_under_corrupt_suffix() {
482 let (g, _lock) = setup();
483 let path = g.path().join("key_activity.json");
484 std::fs::write(&path, b"definitely not json").unwrap();
485 let _ = KeyActivityLog::load();
486 assert!(!path.exists(), "corrupt file should have been renamed");
488 let preserved: Vec<_> = std::fs::read_dir(g.path())
490 .unwrap()
491 .filter_map(|e| e.ok())
492 .filter(|e| {
493 e.file_name()
494 .to_string_lossy()
495 .contains("key_activity.json.corrupt-")
496 })
497 .collect();
498 assert_eq!(preserved.len(), 1);
499 let body = std::fs::read(preserved[0].path()).unwrap();
500 assert_eq!(body, b"definitely not json");
501 }
502
503 #[test]
504 fn flush_in_demo_mode_does_not_write_file() {
505 let (g, _lock) = setup();
506 crate::demo_flag::enable();
507 let mut log = KeyActivityLog::default();
508 log.record("h", now_secs());
509 let result = log.flush();
510 crate::demo_flag::disable();
511
512 assert!(result.is_ok());
513 let path = g.path().join("key_activity.json");
514 assert!(
515 !path.exists(),
516 "demo mode must not write the activity log to disk"
517 );
518 }
519
520 #[test]
521 fn now_for_render_returns_demo_constant_in_demo_mode() {
522 let (_g, _lock) = setup();
523 crate::demo_flag::enable();
524 let n = now_for_render();
525 crate::demo_flag::disable();
526 assert_eq!(n, DEMO_NOW_SECS);
527 }
528
529 #[test]
530 fn now_for_render_returns_wall_clock_outside_demo() {
531 let (_g, _lock) = setup();
532 let before = now_secs();
536 let n = now_for_render();
537 let after = now_secs();
538 assert!(n >= before && n <= after);
539 }
540
541 #[test]
542 fn timestamps_for_aliases_filters_to_matching() {
543 let mut log = KeyActivityLog::default();
544 log.events.push(ConnectEvent {
545 alias: "a".into(),
546 ts: 100,
547 });
548 log.events.push(ConnectEvent {
549 alias: "b".into(),
550 ts: 200,
551 });
552 log.events.push(ConnectEvent {
553 alias: "a".into(),
554 ts: 300,
555 });
556 let ts = log.timestamps_for_aliases(&["a".to_string()]);
557 assert_eq!(ts, vec![100, 300]);
558 }
559}