use std::io;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use log::debug;
use serde::{Deserialize, Serialize};
use crate::fs_util;
const RETENTION_DAYS: u64 = 90;
const SECS_PER_DAY: u64 = 86_400;
pub const DEMO_NOW_SECS: u64 = 1_778_932_800;
#[cfg(test)]
thread_local! {
static PATH_OVERRIDE: std::cell::RefCell<Option<PathBuf>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
pub fn set_path_override(path: PathBuf) {
PATH_OVERRIDE.with(|p| *p.borrow_mut() = Some(path));
}
fn activity_path() -> Option<PathBuf> {
#[cfg(test)]
{
PATH_OVERRIDE.with(|p| p.borrow().clone())
}
#[cfg(not(test))]
{
dirs::home_dir().map(|h| h.join(".purple/key_activity.json"))
}
}
pub fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn now_for_render() -> u64 {
if crate::demo_flag::is_demo() {
DEMO_NOW_SECS
} else {
now_secs()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ConnectEvent {
pub alias: String,
pub ts: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct KeyActivityLog {
pub events: Vec<ConnectEvent>,
}
impl KeyActivityLog {
pub fn load() -> Self {
let Some(path) = activity_path() else {
return Self::default();
};
match std::fs::read_to_string(&path) {
Ok(s) => match serde_json::from_str::<Self>(&s) {
Ok(mut log) => {
log.prune(now_secs());
log
}
Err(e) => {
let backup = path.with_extension(format!("json.corrupt-{}", now_secs()));
if let Err(rename_err) = std::fs::rename(&path, &backup) {
debug!(
"[purple] key_activity: parse failed and could not preserve corrupt file: parse={e} rename={rename_err}",
);
} else {
debug!(
"[purple] key_activity: parse failed, preserved corrupt file at {}: {e}",
backup.display(),
);
}
Self::default()
}
},
Err(e) => {
if e.kind() != io::ErrorKind::NotFound {
debug!("[purple] key_activity: read failed: {e}");
}
Self::default()
}
}
}
pub fn record(&mut self, alias: &str, now: u64) {
self.events.push(ConnectEvent {
alias: alias.to_string(),
ts: now,
});
self.prune(now);
}
fn prune(&mut self, now: u64) {
let cutoff = now.saturating_sub(RETENTION_DAYS * SECS_PER_DAY);
self.events.retain(|e| e.ts >= cutoff);
}
pub fn flush(&self) -> io::Result<()> {
if crate::demo_flag::is_demo() {
debug!(
"[purple] key_activity: demo mode, skipping disk flush ({} events held in memory)",
self.events.len(),
);
return Ok(());
}
let Some(path) = activity_path() else {
return Ok(());
};
let body = serde_json::to_vec_pretty(self)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs_util::atomic_write(&path, &body)
}
pub fn record_oneshot(alias: &str, now: u64) {
let mut log = Self::load();
log.record(alias, now);
if let Err(e) = log.flush() {
debug!("[purple] key_activity: flush failed: {e}");
}
}
pub fn last_use_for_aliases(&self, aliases: &[String]) -> Option<u64> {
let lookup = alias_set(aliases);
self.events
.iter()
.filter(|e| lookup.contains(e.alias.as_str()))
.map(|e| e.ts)
.max()
}
pub fn timestamps_for_aliases(&self, aliases: &[String]) -> Vec<u64> {
let lookup = alias_set(aliases);
self.events
.iter()
.filter(|e| lookup.contains(e.alias.as_str()))
.map(|e| e.ts)
.collect()
}
}
pub fn record_and_flush(log: &mut KeyActivityLog, alias: &str, now: u64) {
log.record(alias, now);
if let Err(e) = log.flush() {
debug!("[purple] key_activity: flush failed: {e}");
}
}
fn alias_set(aliases: &[String]) -> std::collections::HashSet<&str> {
aliases.iter().map(String::as_str).collect()
}
pub fn humanize_last_use(now: u64, ts: u64) -> String {
let diff = now.saturating_sub(ts);
if diff < 60 {
return "just now".to_string();
}
let minutes = diff / 60;
if minutes < 60 {
return format!("{minutes}m ago");
}
let hours = minutes / 60;
if hours < 24 {
return format!("{hours}h ago");
}
let days = hours / 24;
if days < 7 {
return format!("{days}d ago");
}
let weeks = days / 7;
if weeks < 5 {
return format!("{weeks}w ago");
}
let months = days / 30;
if months < 12 {
return format!("{months}mo ago");
}
let years = days / 365;
format!("{years}y ago")
}
pub fn format_created(now: u64, mtime_ts: u64) -> String {
let date = format_yyyy_mm_dd(mtime_ts);
let age = humanize_last_use(now, mtime_ts);
format!("{date} ({age})")
}
fn format_yyyy_mm_dd(ts: u64) -> String {
let days_since_epoch = (ts / SECS_PER_DAY) as i64;
let (y, m, d) = civil_from_days(days_since_epoch);
format!("{:04}-{:02}-{:02}", y, m, d)
}
fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = y + if m <= 2 { 1 } else { 0 };
(y as i32, m as u32, d as u32)
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
let guard = crate::demo_flag::GLOBAL_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let dir = tempfile::tempdir().expect("tempdir");
set_path_override(dir.path().join("key_activity.json"));
(dir, guard)
}
#[test]
fn record_appends_event() {
let (_g, _lock) = setup();
let mut log = KeyActivityLog::default();
log.record("prod-eu1", now_secs());
assert_eq!(log.events.len(), 1);
assert_eq!(log.events[0].alias, "prod-eu1");
}
#[test]
fn record_prunes_events_past_retention() {
let (_g, _lock) = setup();
let mut log = KeyActivityLog::default();
let now = now_secs();
let very_old = now - (RETENTION_DAYS + 10) * SECS_PER_DAY;
log.events.push(ConnectEvent {
alias: "ancient".into(),
ts: very_old,
});
log.record("fresh", now);
assert_eq!(log.events.len(), 1);
assert_eq!(log.events[0].alias, "fresh");
}
#[test]
fn load_after_flush_roundtrips() {
let (_g, _lock) = setup();
let mut log = KeyActivityLog::default();
let now = now_secs();
log.record("eric-bastion", now);
log.record("aws-api-prod", now);
log.flush().unwrap();
let reloaded = KeyActivityLog::load();
assert_eq!(reloaded.events.len(), 2);
}
#[test]
fn load_missing_file_returns_default() {
let (_g, _lock) = setup();
let log = KeyActivityLog::load();
assert!(log.events.is_empty());
}
#[test]
fn last_use_returns_most_recent() {
let (_g, _lock) = setup();
let mut log = KeyActivityLog::default();
log.events.push(ConnectEvent {
alias: "h".into(),
ts: 100,
});
log.events.push(ConnectEvent {
alias: "h".into(),
ts: 500,
});
log.events.push(ConnectEvent {
alias: "h".into(),
ts: 300,
});
let aliases = vec!["h".to_string()];
assert_eq!(log.last_use_for_aliases(&aliases), Some(500));
}
#[test]
fn last_use_none_for_no_matches() {
let (_g, _lock) = setup();
let log = KeyActivityLog::default();
let aliases = vec!["nobody".to_string()];
assert!(log.last_use_for_aliases(&aliases).is_none());
}
#[test]
fn humanize_last_use_buckets() {
assert_eq!(humanize_last_use(1000, 999), "just now");
assert_eq!(humanize_last_use(1000, 600), "6m ago");
assert_eq!(humanize_last_use(SECS_PER_DAY * 2, 0), "2d ago");
assert_eq!(humanize_last_use(SECS_PER_DAY * 14, 0), "2w ago");
assert_eq!(humanize_last_use(SECS_PER_DAY * 60, 0), "2mo ago");
assert_eq!(humanize_last_use(SECS_PER_DAY * 400, 0), "1y ago");
}
#[test]
fn record_oneshot_persists_to_disk() {
let (_g, _lock) = setup();
KeyActivityLog::record_oneshot("h1", now_secs());
let reloaded = KeyActivityLog::load();
assert_eq!(reloaded.events.len(), 1);
assert_eq!(reloaded.events[0].alias, "h1");
}
#[test]
fn civil_from_days_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(19794), (2024, 3, 12));
assert_eq!(civil_from_days(20589), (2026, 5, 16));
}
#[test]
fn format_yyyy_mm_dd_known() {
assert_eq!(format_yyyy_mm_dd(1_778_932_800), "2026-05-16");
assert_eq!(format_yyyy_mm_dd(1_710_244_800), "2024-03-12");
}
#[test]
fn format_created_combines_date_and_age() {
let now = 1_778_932_800;
let created = 1_710_244_800; let out = format_created(now, created);
assert!(out.starts_with("2024-03-12 ("));
assert!(out.ends_with(" ago)"));
}
#[test]
fn humanize_boundary_60s_is_1m_not_just_now() {
assert_eq!(humanize_last_use(1000, 940), "1m ago");
}
#[test]
fn humanize_boundary_exactly_1h() {
assert_eq!(humanize_last_use(3600, 0), "1h ago");
}
#[test]
fn humanize_boundary_exactly_7d() {
assert_eq!(humanize_last_use(SECS_PER_DAY * 7, 0), "1w ago");
}
#[test]
fn humanize_boundary_35d_falls_to_months() {
assert_eq!(humanize_last_use(SECS_PER_DAY * 35, 0), "1mo ago");
}
#[test]
fn prune_keeps_event_at_exactly_retention_boundary() {
let (_g, _lock) = setup();
let now = 200 * SECS_PER_DAY;
let mut log = KeyActivityLog::default();
log.events.push(ConnectEvent {
alias: "edge".into(),
ts: now - RETENTION_DAYS * SECS_PER_DAY,
});
log.prune(now);
assert_eq!(log.events.len(), 1);
}
#[test]
fn civil_from_days_leap_day_2000() {
assert_eq!(civil_from_days(11016), (2000, 2, 29));
}
#[test]
fn load_corrupt_json_returns_empty_log() {
let (g, _lock) = setup();
let path = g.path().join("key_activity.json");
std::fs::write(&path, b"not valid json {{").unwrap();
let log = KeyActivityLog::load();
assert!(log.events.is_empty());
}
#[test]
fn load_corrupt_json_preserves_file_under_corrupt_suffix() {
let (g, _lock) = setup();
let path = g.path().join("key_activity.json");
std::fs::write(&path, b"definitely not json").unwrap();
let _ = KeyActivityLog::load();
assert!(!path.exists(), "corrupt file should have been renamed");
let preserved: Vec<_> = std::fs::read_dir(g.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.contains("key_activity.json.corrupt-")
})
.collect();
assert_eq!(preserved.len(), 1);
let body = std::fs::read(preserved[0].path()).unwrap();
assert_eq!(body, b"definitely not json");
}
#[test]
fn flush_in_demo_mode_does_not_write_file() {
let (g, _lock) = setup();
crate::demo_flag::enable();
let mut log = KeyActivityLog::default();
log.record("h", now_secs());
let result = log.flush();
crate::demo_flag::disable();
assert!(result.is_ok());
let path = g.path().join("key_activity.json");
assert!(
!path.exists(),
"demo mode must not write the activity log to disk"
);
}
#[test]
fn now_for_render_returns_demo_constant_in_demo_mode() {
let (_g, _lock) = setup();
crate::demo_flag::enable();
let n = now_for_render();
crate::demo_flag::disable();
assert_eq!(n, DEMO_NOW_SECS);
}
#[test]
fn now_for_render_returns_wall_clock_outside_demo() {
let (_g, _lock) = setup();
let before = now_secs();
let n = now_for_render();
let after = now_secs();
assert!(n >= before && n <= after);
}
#[test]
fn timestamps_for_aliases_filters_to_matching() {
let mut log = KeyActivityLog::default();
log.events.push(ConnectEvent {
alias: "a".into(),
ts: 100,
});
log.events.push(ConnectEvent {
alias: "b".into(),
ts: 200,
});
log.events.push(ConnectEvent {
alias: "a".into(),
ts: 300,
});
let ts = log.timestamps_for_aliases(&["a".to_string()]);
assert_eq!(ts, vec![100, 300]);
}
}