use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Serialize, Deserialize)]
pub struct Checkpoint {
pub throttle_timestamp: Option<String>,
#[serde(default = "default_machine_status")]
pub machine_status: String,
#[serde(default)]
pub auth_error: bool,
#[serde(default)]
pub date_hashes: HashMap<String, String>,
}
fn default_machine_status() -> String {
"active".to_string()
}
impl Default for Checkpoint {
fn default() -> Self {
Self {
throttle_timestamp: None,
machine_status: default_machine_status(),
auth_error: false,
date_hashes: HashMap::new(),
}
}
}
#[cfg_attr(not(test), allow(dead_code))]
fn parse_iso8601_utc(s: &str) -> Option<std::time::SystemTime> {
let s = s.strip_suffix('Z')?;
let (date_str, time_str) = s.split_once('T')?;
let mut dp = date_str.split('-');
let year: u64 = dp.next()?.parse().ok()?;
let month: u64 = dp.next()?.parse().ok()?;
let day: u64 = dp.next()?.parse().ok()?;
if dp.next().is_some() {
return None; }
let mut tp = time_str.split(':');
let hour: u64 = tp.next()?.parse().ok()?;
let min: u64 = tp.next()?.parse().ok()?;
let sec: u64 = tp.next()?.parse().ok()?;
if tp.next().is_some() {
return None; }
if year < 1970 || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
if hour >= 24 || min >= 60 || sec >= 60 {
return None;
}
let y = if month <= 2 { year - 1 } else { year };
let era = y / 400;
let yoe = y - era * 400; let doy = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; let days_since_epoch = era * 146097 + doe - 719468;
let secs = days_since_epoch * 86400 + hour * 3600 + min * 60 + sec;
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs))
}
#[cfg_attr(not(test), allow(dead_code))]
fn format_iso8601_utc(t: std::time::SystemTime) -> String {
let secs = t
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let z = secs / 86400;
let time_of_day = secs % 86400;
let h = time_of_day / 3600;
let m = (time_of_day % 3600) / 60;
let s = time_of_day % 60;
let z = z + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + 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 mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, m, s)
}
impl Checkpoint {
pub fn load(path: &Path) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|s| toml::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let content = toml::to_string(self)?;
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let mut tmp_os = path.as_os_str().to_os_string();
tmp_os.push(".tmp");
let tmp_path = std::path::PathBuf::from(tmp_os);
std::fs::write(&tmp_path, content)?;
std::fs::rename(&tmp_path, path)?;
Ok(())
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn should_throttle(&self) -> bool {
let ts_str = match &self.throttle_timestamp {
Some(ts) => ts,
None => return false, };
let ts = match parse_iso8601_utc(ts_str) {
Some(t) => t,
None => return false, };
let now = std::time::SystemTime::now();
match now.duration_since(ts) {
Ok(elapsed) => elapsed.as_secs() < 300, Err(_) => false, }
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn update_throttle_timestamp(&mut self) {
self.throttle_timestamp = Some(format_iso8601_utc(std::time::SystemTime::now()));
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn hash_matches(&self, date: &str, hash: &str) -> bool {
self.date_hashes
.get(date)
.map(|stored| stored == hash)
.unwrap_or(false)
}
pub fn hash_matches_for_harness(&self, harness: &str, date: &str, hash: &str) -> bool {
let key = harness_date_key(harness, date);
self.date_hashes
.get(&key)
.or_else(|| {
if harness == "claude" {
self.date_hashes.get(date)
} else {
None
}
})
.map(|stored| stored == hash)
.unwrap_or(false)
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn update_hash(&mut self, date: &str, hash: &str) {
self.date_hashes.insert(date.to_string(), hash.to_string());
}
pub fn update_hash_for_harness(&mut self, harness: &str, date: &str, hash: &str) {
self.date_hashes
.insert(harness_date_key(harness, date), hash.to_string());
}
pub fn set_auth_error(&mut self) {
self.auth_error = true;
}
pub fn clear_auth_error(&mut self) {
self.auth_error = false;
}
pub fn is_retired(&self) -> bool {
self.machine_status == "retired"
}
pub fn set_machine_status(&mut self, status: &str) {
self.machine_status = status.to_string();
}
pub fn get_last_sync_date(&self) -> Option<String> {
let mut latest_by_harness: HashMap<String, String> = HashMap::new();
for key in self.date_hashes.keys() {
let Some((harness, date)) = harness_and_date_from_hash_key(key) else {
continue;
};
let entry = latest_by_harness
.entry(harness)
.or_insert_with(|| date.clone());
if date > *entry {
*entry = date;
}
}
latest_by_harness.into_values().min()
}
}
fn harness_date_key(harness: &str, date: &str) -> String {
format!("{harness}:{date}")
}
fn harness_and_date_from_hash_key(key: &str) -> Option<(String, String)> {
let (harness, date) = key.split_once(':').unwrap_or(("claude", key));
if date.len() == 10 {
Some((harness.to_string(), date.to_string()))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn temp_path(name: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("vibestats_{name}_{pid}_{nanos}_{seq}.toml"))
}
#[test]
fn load_missing_file_returns_default() {
let path = temp_path("missing");
let _ = std::fs::remove_file(&path);
let cp = Checkpoint::load(&path);
assert!(!cp.auth_error);
assert_eq!(cp.machine_status, "active");
assert!(cp.date_hashes.is_empty());
}
#[test]
fn save_load_roundtrip() {
let path = temp_path("roundtrip");
let mut cp = Checkpoint::default();
cp.set_auth_error();
cp.update_hash("2026-04-10", "abc123");
cp.save(&path).unwrap();
let loaded = Checkpoint::load(&path);
assert!(loaded.auth_error);
assert!(loaded.hash_matches("2026-04-10", "abc123"));
let _ = std::fs::remove_file(&path); }
#[test]
fn should_throttle_recent_timestamp() {
let mut cp = Checkpoint::default();
cp.update_throttle_timestamp(); assert!(cp.should_throttle()); }
#[test]
fn should_throttle_old_timestamp_returns_false() {
let cp = Checkpoint {
throttle_timestamp: Some("2020-01-01T00:00:00Z".to_string()),
..Default::default()
};
assert!(!cp.should_throttle());
}
#[test]
fn hash_matches_correct_and_incorrect() {
let mut cp = Checkpoint::default();
cp.update_hash("2026-04-10", "deadbeef");
assert!(cp.hash_matches("2026-04-10", "deadbeef"));
assert!(!cp.hash_matches("2026-04-10", "wronghash"));
assert!(!cp.hash_matches("2026-04-11", "deadbeef")); }
#[test]
fn harness_hashes_do_not_collide_on_same_date() {
let mut cp = Checkpoint::default();
cp.update_hash_for_harness("claude", "2026-05-03", "claudehash");
cp.update_hash_for_harness("codex", "2026-05-03", "codexhash");
assert!(cp.hash_matches_for_harness("claude", "2026-05-03", "claudehash"));
assert!(cp.hash_matches_for_harness("codex", "2026-05-03", "codexhash"));
assert!(!cp.hash_matches_for_harness("codex", "2026-05-03", "claudehash"));
}
#[test]
fn claude_harness_matches_legacy_date_only_hash() {
let mut cp = Checkpoint::default();
cp.update_hash("2026-05-03", "legacyhash");
assert!(cp.hash_matches_for_harness("claude", "2026-05-03", "legacyhash"));
assert!(!cp.hash_matches_for_harness("codex", "2026-05-03", "legacyhash"));
}
#[test]
fn auth_error_roundtrip() {
let mut cp = Checkpoint::default();
assert!(!cp.auth_error);
cp.set_auth_error();
assert!(cp.auth_error);
cp.clear_auth_error();
assert!(!cp.auth_error);
}
#[test]
fn is_retired_variants() {
let mut cp = Checkpoint::default();
assert!(!cp.is_retired()); cp.set_machine_status("retired");
assert!(cp.is_retired());
cp.set_machine_status("purged");
assert!(!cp.is_retired()); cp.set_machine_status("active");
assert!(!cp.is_retired());
}
#[test]
fn get_last_sync_date_empty_returns_none() {
let cp = Checkpoint::default();
assert!(cp.get_last_sync_date().is_none());
}
#[test]
fn get_last_sync_date_returns_max_key() {
let mut cp = Checkpoint::default();
cp.update_hash("2026-03-10", "hash1");
cp.update_hash("2026-04-11", "hash2");
cp.update_hash("2026-01-01", "hash3");
assert_eq!(cp.get_last_sync_date(), Some("2026-04-11".to_string()));
}
#[test]
fn get_last_sync_date_handles_harness_keys() {
let mut cp = Checkpoint::default();
cp.update_hash_for_harness("claude", "2026-03-10", "hash1");
cp.update_hash_for_harness("codex", "2026-04-12", "hash2");
cp.update_hash("2026-04-11", "hash3");
assert_eq!(cp.get_last_sync_date(), Some("2026-04-11".to_string()));
}
#[test]
fn get_last_sync_date_single_entry() {
let mut cp = Checkpoint::default();
cp.update_hash("2026-04-10", "hash1");
assert_eq!(cp.get_last_sync_date(), Some("2026-04-10".to_string()));
}
#[test]
fn parse_iso8601_rejects_missing_z() {
assert!(parse_iso8601_utc("2026-04-10T14:23:00").is_none());
}
#[test]
fn parse_iso8601_rejects_out_of_range() {
assert!(parse_iso8601_utc("2026-13-01T00:00:00Z").is_none()); assert!(parse_iso8601_utc("2026-00-01T00:00:00Z").is_none()); assert!(parse_iso8601_utc("2026-04-32T00:00:00Z").is_none()); assert!(parse_iso8601_utc("2026-04-00T00:00:00Z").is_none()); assert!(parse_iso8601_utc("2026-04-10T24:00:00Z").is_none()); assert!(parse_iso8601_utc("2026-04-10T00:60:00Z").is_none()); assert!(parse_iso8601_utc("2026-04-10T00:00:60Z").is_none()); }
#[test]
fn parse_iso8601_rejects_pre_1970() {
assert!(parse_iso8601_utc("1969-12-31T23:59:59Z").is_none());
assert!(parse_iso8601_utc("0001-01-01T00:00:00Z").is_none());
}
#[test]
fn format_parse_iso8601_roundtrip() {
let now = std::time::SystemTime::now();
let formatted = format_iso8601_utc(now);
let parsed = parse_iso8601_utc(&formatted).expect("format output must parse");
let now_secs = now.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let parsed_secs = parsed
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
assert_eq!(now_secs, parsed_secs);
}
#[test]
fn save_is_atomic_no_tmp_left_behind() {
let path = temp_path("atomic");
let cp = Checkpoint::default();
cp.save(&path).unwrap();
let mut tmp_os = path.as_os_str().to_os_string();
tmp_os.push(".tmp");
let tmp_path = std::path::PathBuf::from(tmp_os);
assert!(!tmp_path.exists(), "temp file must be renamed away");
assert!(path.exists(), "target file must exist");
let _ = std::fs::remove_file(&path);
}
}