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;
20use crate::runtime::env::Paths;
21
22const RETENTION_DAYS: u64 = 90;
27
28const SECS_PER_DAY: u64 = 86_400;
29
30pub const DEMO_NOW_SECS: u64 = 1_778_932_800; fn activity_path(paths: Option<&Paths>) -> Option<PathBuf> {
37 paths.map(Paths::key_activity)
38}
39
40pub fn now_secs() -> u64 {
44 SystemTime::now()
45 .duration_since(UNIX_EPOCH)
46 .map(|d| d.as_secs())
47 .unwrap_or(0)
48}
49
50pub fn now_for_render() -> u64 {
56 if crate::demo_flag::is_demo() {
57 DEMO_NOW_SECS
58 } else {
59 now_secs()
60 }
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
64pub struct ConnectEvent {
65 pub alias: String,
66 pub ts: u64,
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71pub struct KeyActivityLog {
72 pub events: Vec<ConnectEvent>,
73}
74
75impl KeyActivityLog {
76 pub fn load(paths: Option<&Paths>) -> Self {
81 let Some(path) = activity_path(paths) else {
82 return Self::default();
83 };
84 match crate::fs_util::read_json_recovering::<Self>(&path, now_secs()) {
85 Some(mut log) => {
86 log.prune(now_secs());
87 log
88 }
89 None => Self::default(),
90 }
91 }
92
93 pub fn record(&mut self, alias: &str, now: u64) {
98 self.events.push(ConnectEvent {
99 alias: alias.to_string(),
100 ts: now,
101 });
102 self.prune(now);
103 }
104
105 fn prune(&mut self, now: u64) {
106 let cutoff = now.saturating_sub(RETENTION_DAYS * SECS_PER_DAY);
107 self.events.retain(|e| e.ts >= cutoff);
108 }
109
110 pub fn flush(&self, paths: Option<&Paths>) -> io::Result<()> {
117 if crate::demo_flag::is_demo() {
118 debug!(
119 "[purple] key_activity: demo mode, skipping disk flush ({} events held in memory)",
120 self.events.len(),
121 );
122 return Ok(());
123 }
124 let Some(path) = activity_path(paths) else {
125 return Ok(());
126 };
127 fs_util::write_json_pretty(&path, self)
128 }
129
130 pub fn record_oneshot(alias: &str, now: u64, paths: Option<&Paths>) {
134 let mut log = Self::load(paths);
135 log.record(alias, now);
136 if let Err(e) = log.flush(paths) {
137 debug!("[purple] key_activity: flush failed: {e}");
138 }
139 }
140
141 pub fn last_use_for_aliases(&self, aliases: &[String]) -> Option<u64> {
143 let lookup = alias_set(aliases);
144 self.events
145 .iter()
146 .filter(|e| lookup.contains(e.alias.as_str()))
147 .map(|e| e.ts)
148 .max()
149 }
150
151 pub fn timestamps_for_aliases(&self, aliases: &[String]) -> Vec<u64> {
155 let lookup = alias_set(aliases);
156 self.events
157 .iter()
158 .filter(|e| lookup.contains(e.alias.as_str()))
159 .map(|e| e.ts)
160 .collect()
161 }
162}
163
164pub fn record_and_flush(log: &mut KeyActivityLog, alias: &str, now: u64, paths: Option<&Paths>) {
171 log.record(alias, now);
172 if let Err(e) = log.flush(paths) {
173 debug!("[purple] key_activity: flush failed: {e}");
174 }
175}
176
177fn alias_set(aliases: &[String]) -> std::collections::HashSet<&str> {
180 aliases.iter().map(String::as_str).collect()
181}
182
183pub fn humanize_last_use(now: u64, ts: u64) -> String {
188 let diff = now.saturating_sub(ts);
189 if diff < 60 {
190 return "just now".to_string();
191 }
192 let minutes = diff / 60;
193 if minutes < 60 {
194 return format!("{minutes}m ago");
195 }
196 let hours = minutes / 60;
197 if hours < 24 {
198 return format!("{hours}h ago");
199 }
200 let days = hours / 24;
201 if days < 7 {
202 return format!("{days}d ago");
203 }
204 let weeks = days / 7;
205 if weeks < 5 {
206 return format!("{weeks}w ago");
207 }
208 let months = days / 30;
209 if months < 12 {
210 return format!("{months}mo ago");
211 }
212 let years = days / 365;
213 format!("{years}y ago")
214}
215
216pub fn format_created(now: u64, mtime_ts: u64) -> String {
220 let date = format_yyyy_mm_dd(mtime_ts);
221 let age = humanize_last_use(now, mtime_ts);
222 format!("{date} ({age})")
223}
224
225fn format_yyyy_mm_dd(ts: u64) -> String {
228 let days_since_epoch = (ts / SECS_PER_DAY) as i64;
229 let (y, m, d) = civil_from_days(days_since_epoch);
230 format!("{:04}-{:02}-{:02}", y, m, d)
231}
232
233fn civil_from_days(z: i64) -> (i32, u32, u32) {
237 let z = z + 719_468;
238 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
239 let doe = (z - era * 146_097) as u64;
240 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
241 let y = yoe as i64 + era * 400;
242 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
243 let mp = (5 * doy + 2) / 153;
244 let d = doy - (153 * mp + 2) / 5 + 1;
245 let m = if mp < 10 { mp + 3 } else { mp - 9 };
246 let y = y + if m <= 2 { 1 } else { 0 };
247 (y as i32, m as u32, d as u32)
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn setup() -> (tempfile::TempDir, Paths, std::sync::MutexGuard<'static, ()>) {
261 let guard = crate::demo_flag::GLOBAL_TEST_LOCK
262 .lock()
263 .unwrap_or_else(|p| p.into_inner());
264 let dir = tempfile::tempdir().expect("tempdir");
265 let paths = Paths::new(dir.path());
266 (dir, paths, guard)
267 }
268
269 #[test]
270 fn record_appends_event() {
271 let (_g, _paths, _lock) = setup();
272 let mut log = KeyActivityLog::default();
273 log.record("prod-eu1", now_secs());
274 assert_eq!(log.events.len(), 1);
275 assert_eq!(log.events[0].alias, "prod-eu1");
276 }
277
278 #[test]
279 fn record_prunes_events_past_retention() {
280 let (_g, _paths, _lock) = setup();
281 let mut log = KeyActivityLog::default();
282 let now = now_secs();
283 let very_old = now - (RETENTION_DAYS + 10) * SECS_PER_DAY;
284 log.events.push(ConnectEvent {
285 alias: "ancient".into(),
286 ts: very_old,
287 });
288 log.record("fresh", now);
289 assert_eq!(log.events.len(), 1);
290 assert_eq!(log.events[0].alias, "fresh");
291 }
292
293 #[test]
294 fn load_after_flush_roundtrips() {
295 let (_g, paths, _lock) = setup();
296 let mut log = KeyActivityLog::default();
297 let now = now_secs();
298 log.record("eric-bastion", now);
299 log.record("aws-api-prod", now);
300 log.flush(Some(&paths)).unwrap();
301 let reloaded = KeyActivityLog::load(Some(&paths));
302 assert_eq!(reloaded.events.len(), 2);
303 }
304
305 #[test]
306 fn load_missing_file_returns_default() {
307 let (_g, paths, _lock) = setup();
308 let log = KeyActivityLog::load(Some(&paths));
309 assert!(log.events.is_empty());
310 }
311
312 #[test]
313 fn last_use_returns_most_recent() {
314 let (_g, _paths, _lock) = setup();
315 let mut log = KeyActivityLog::default();
316 log.events.push(ConnectEvent {
317 alias: "h".into(),
318 ts: 100,
319 });
320 log.events.push(ConnectEvent {
321 alias: "h".into(),
322 ts: 500,
323 });
324 log.events.push(ConnectEvent {
325 alias: "h".into(),
326 ts: 300,
327 });
328 let aliases = vec!["h".to_string()];
329 assert_eq!(log.last_use_for_aliases(&aliases), Some(500));
330 }
331
332 #[test]
333 fn last_use_none_for_no_matches() {
334 let (_g, _paths, _lock) = setup();
335 let log = KeyActivityLog::default();
336 let aliases = vec!["nobody".to_string()];
337 assert!(log.last_use_for_aliases(&aliases).is_none());
338 }
339
340 #[test]
341 fn humanize_last_use_buckets() {
342 assert_eq!(humanize_last_use(1000, 999), "just now");
343 assert_eq!(humanize_last_use(1000, 600), "6m ago");
344 assert_eq!(humanize_last_use(SECS_PER_DAY * 2, 0), "2d ago");
345 assert_eq!(humanize_last_use(SECS_PER_DAY * 14, 0), "2w ago");
346 assert_eq!(humanize_last_use(SECS_PER_DAY * 60, 0), "2mo ago");
347 assert_eq!(humanize_last_use(SECS_PER_DAY * 400, 0), "1y ago");
348 }
349
350 #[test]
351 fn record_oneshot_persists_to_disk() {
352 let (_g, paths, _lock) = setup();
353 KeyActivityLog::record_oneshot("h1", now_secs(), Some(&paths));
354 let reloaded = KeyActivityLog::load(Some(&paths));
355 assert_eq!(reloaded.events.len(), 1);
356 assert_eq!(reloaded.events[0].alias, "h1");
357 }
358
359 #[test]
360 fn civil_from_days_known_dates() {
361 assert_eq!(civil_from_days(0), (1970, 1, 1));
363 assert_eq!(civil_from_days(19794), (2024, 3, 12));
365 assert_eq!(civil_from_days(20589), (2026, 5, 16));
367 }
368
369 #[test]
370 fn format_yyyy_mm_dd_known() {
371 assert_eq!(format_yyyy_mm_dd(1_778_932_800), "2026-05-16");
373 assert_eq!(format_yyyy_mm_dd(1_710_244_800), "2024-03-12");
375 }
376
377 #[test]
378 fn format_created_combines_date_and_age() {
379 let now = 1_778_932_800;
380 let created = 1_710_244_800; let out = format_created(now, created);
382 assert!(out.starts_with("2024-03-12 ("));
383 assert!(out.ends_with(" ago)"));
384 }
385
386 #[test]
389 fn humanize_boundary_60s_is_1m_not_just_now() {
390 assert_eq!(humanize_last_use(1000, 940), "1m ago");
391 }
392
393 #[test]
394 fn humanize_boundary_exactly_1h() {
395 assert_eq!(humanize_last_use(3600, 0), "1h ago");
396 }
397
398 #[test]
399 fn humanize_boundary_exactly_7d() {
400 assert_eq!(humanize_last_use(SECS_PER_DAY * 7, 0), "1w ago");
401 }
402
403 #[test]
404 fn humanize_boundary_35d_falls_to_months() {
405 assert_eq!(humanize_last_use(SECS_PER_DAY * 35, 0), "1mo ago");
408 }
409
410 #[test]
411 fn prune_keeps_event_at_exactly_retention_boundary() {
412 let (_g, _paths, _lock) = setup();
413 let now = 200 * SECS_PER_DAY;
414 let mut log = KeyActivityLog::default();
415 log.events.push(ConnectEvent {
416 alias: "edge".into(),
417 ts: now - RETENTION_DAYS * SECS_PER_DAY,
418 });
419 log.prune(now);
420 assert_eq!(log.events.len(), 1);
421 }
422
423 #[test]
424 fn civil_from_days_leap_day_2000() {
425 assert_eq!(civil_from_days(11016), (2000, 2, 29));
427 }
428
429 #[test]
430 fn load_corrupt_json_returns_empty_log() {
431 let (_g, paths, _lock) = setup();
432 let path = paths.key_activity();
433 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
434 std::fs::write(&path, b"not valid json {{").unwrap();
435 let log = KeyActivityLog::load(Some(&paths));
436 assert!(log.events.is_empty());
437 }
438
439 #[test]
440 fn load_corrupt_json_preserves_file_under_corrupt_suffix() {
441 let (_g, paths, _lock) = setup();
442 let path = paths.key_activity();
443 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
444 std::fs::write(&path, b"definitely not json").unwrap();
445 let _ = KeyActivityLog::load(Some(&paths));
446 assert!(!path.exists(), "corrupt file should have been renamed");
448 let preserved: Vec<_> = std::fs::read_dir(path.parent().unwrap())
450 .unwrap()
451 .filter_map(|e| e.ok())
452 .filter(|e| {
453 e.file_name()
454 .to_string_lossy()
455 .contains("key_activity.json.corrupt-")
456 })
457 .collect();
458 assert_eq!(preserved.len(), 1);
459 let body = std::fs::read(preserved[0].path()).unwrap();
460 assert_eq!(body, b"definitely not json");
461 }
462
463 #[test]
464 fn flush_in_demo_mode_does_not_write_file() {
465 let (_g, paths, _lock) = setup();
466 crate::demo_flag::enable();
467 let mut log = KeyActivityLog::default();
468 log.record("h", now_secs());
469 let result = log.flush(Some(&paths));
470 crate::demo_flag::disable();
471
472 assert!(result.is_ok());
473 let path = paths.key_activity();
474 assert!(
475 !path.exists(),
476 "demo mode must not write the activity log to disk"
477 );
478 }
479
480 #[test]
481 fn now_for_render_returns_demo_constant_in_demo_mode() {
482 let (_g, _paths, _lock) = setup();
483 crate::demo_flag::enable();
484 let n = now_for_render();
485 crate::demo_flag::disable();
486 assert_eq!(n, DEMO_NOW_SECS);
487 }
488
489 #[test]
490 fn now_for_render_returns_wall_clock_outside_demo() {
491 let (_g, _paths, _lock) = setup();
492 let before = now_secs();
496 let n = now_for_render();
497 let after = now_secs();
498 assert!(n >= before && n <= after);
499 }
500
501 #[test]
502 fn timestamps_for_aliases_filters_to_matching() {
503 let mut log = KeyActivityLog::default();
504 log.events.push(ConnectEvent {
505 alias: "a".into(),
506 ts: 100,
507 });
508 log.events.push(ConnectEvent {
509 alias: "b".into(),
510 ts: 200,
511 });
512 log.events.push(ConnectEvent {
513 alias: "a".into(),
514 ts: 300,
515 });
516 let ts = log.timestamps_for_aliases(&["a".to_string()]);
517 assert_eq!(ts, vec![100, 300]);
518 }
519}