use fs2::FileExt;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub const TRACKER_FILE_NAME: &str = "reference-tracker.json";
const SCHEMA_VERSION: &str = "reference-tracker.v1";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReferenceMap {
#[serde(default)]
pub schema_version: String,
#[serde(default)]
pub records: BTreeMap<String, ReferenceEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReferenceEntry {
pub last_referenced_at: String,
pub count: u64,
}
pub fn tracker_path(root: &Path) -> PathBuf {
root.join(TRACKER_FILE_NAME)
}
pub fn touch(root: &Path, record_ids: &[&str]) {
if record_ids.is_empty() {
return;
}
if let Err(err) = touch_inner(root, record_ids) {
eprintln!("[spool] reference tracker touch failed: {err}");
}
}
pub fn read(root: &Path) -> ReferenceMap {
let path = tracker_path(root);
if !path.exists() {
return ReferenceMap::default();
}
match fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => ReferenceMap::default(),
}
}
pub fn age_days(entry: &ReferenceEntry) -> Option<u64> {
let referenced_secs = parse_iso8601_to_unix_secs(&entry.last_referenced_at)?;
let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
if now_secs < referenced_secs {
return Some(0);
}
Some((now_secs - referenced_secs) / 86400)
}
pub fn staleness_penalty(age: Option<u64>) -> i32 {
match age {
None => 0,
Some(days) => match days {
0..=3 => 4,
4..=7 => 2,
8..=14 => 0,
15..=30 => -2,
31..=60 => -4,
61..=90 => -6,
_ => -8,
},
}
}
fn touch_inner(root: &Path, record_ids: &[&str]) -> anyhow::Result<()> {
fs::create_dir_all(root)
.map_err(|e| anyhow::anyhow!("creating tracker dir {}: {e}", root.display()))?;
let path = tracker_path(root);
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)
.map_err(|e| anyhow::anyhow!("opening tracker {}: {e}", path.display()))?;
file.lock_exclusive()
.map_err(|e| anyhow::anyhow!("locking tracker {}: {e}", path.display()))?;
let result = (|| -> anyhow::Result<()> {
let mut content = String::new();
let mut reader =
File::open(&path).map_err(|e| anyhow::anyhow!("re-reading tracker: {e}"))?;
reader.read_to_string(&mut content).ok();
let mut map: ReferenceMap = if content.trim().is_empty() {
ReferenceMap::default()
} else {
serde_json::from_str(&content).unwrap_or_default()
};
map.schema_version = SCHEMA_VERSION.to_string();
let now = now_iso8601();
for &id in record_ids {
let entry = map.records.entry(id.to_string()).or_insert(ReferenceEntry {
last_referenced_at: now.clone(),
count: 0,
});
entry.last_referenced_at = now.clone();
entry.count += 1;
}
let serialized = serde_json::to_string_pretty(&map)
.map_err(|e| anyhow::anyhow!("serializing tracker: {e}"))?;
fs::write(&path, serialized)
.map_err(|e| anyhow::anyhow!("writing tracker {}: {e}", path.display()))?;
Ok(())
})();
let _ = FileExt::unlock(&file);
result
}
fn now_iso8601() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
unix_secs_to_iso8601(secs)
}
fn unix_secs_to_iso8601(secs: u64) -> String {
let days = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let (year, month, day) = days_to_ymd(days);
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
let z = days + 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 m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn parse_iso8601_to_unix_secs(s: &str) -> Option<u64> {
let s = s.trim();
if s.len() < 20 {
return None;
}
let year: u64 = s.get(0..4)?.parse().ok()?;
if s.as_bytes().get(4)? != &b'-' {
return None;
}
let month: u64 = s.get(5..7)?.parse().ok()?;
if s.as_bytes().get(7)? != &b'-' {
return None;
}
let day: u64 = s.get(8..10)?.parse().ok()?;
if s.as_bytes().get(10)? != &b'T' {
return None;
}
let hour: u64 = s.get(11..13)?.parse().ok()?;
if s.as_bytes().get(13)? != &b':' {
return None;
}
let min: u64 = s.get(14..16)?.parse().ok()?;
if s.as_bytes().get(16)? != &b':' {
return None;
}
let sec: u64 = s.get(17..19)?.parse().ok()?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || min > 59 || sec > 59 {
return None;
}
let tz_part = s.get(19..)?;
if tz_part != "Z" && tz_part != "+00:00" && tz_part != "-00:00" {
return None;
}
let days = ymd_to_days(year, month, day)?;
Some(days * 86400 + hour * 3600 + min * 60 + sec)
}
fn ymd_to_days(year: u64, month: u64, day: u64) -> Option<u64> {
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
let y = if month <= 2 { year - 1 } else { year };
let m = if month <= 2 { month + 9 } else { month - 3 };
let era = y / 400;
let yoe = y - era * 400;
let doy = (153 * m + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe;
days.checked_sub(719468)
}
#[cfg(test)]
pub mod tests {
use super::*;
use tempfile::tempdir;
pub fn unix_secs_to_iso8601_for_test(secs: u64) -> String {
super::unix_secs_to_iso8601(secs)
}
#[test]
fn touch_creates_file_when_absent() {
let temp = tempdir().unwrap();
let root = temp.path();
assert!(!tracker_path(root).exists());
touch(root, &["rec-001", "rec-002"]);
assert!(tracker_path(root).exists());
let map = read(root);
assert_eq!(map.schema_version, SCHEMA_VERSION);
assert_eq!(map.records.len(), 2);
assert!(map.records.contains_key("rec-001"));
assert!(map.records.contains_key("rec-002"));
}
#[test]
fn touch_updates_existing_entry() {
let temp = tempdir().unwrap();
let root = temp.path();
let mut map = ReferenceMap {
schema_version: SCHEMA_VERSION.to_string(),
records: BTreeMap::new(),
};
map.records.insert(
"rec-001".to_string(),
ReferenceEntry {
last_referenced_at: "2020-01-01T00:00:00Z".to_string(),
count: 5,
},
);
fs::create_dir_all(root).unwrap();
fs::write(
tracker_path(root),
serde_json::to_string_pretty(&map).unwrap(),
)
.unwrap();
touch(root, &["rec-001"]);
let updated = read(root);
let entry = updated.records.get("rec-001").unwrap();
assert_ne!(entry.last_referenced_at, "2020-01-01T00:00:00Z");
assert!(entry.last_referenced_at.ends_with('Z'));
}
#[test]
fn touch_increments_count() {
let temp = tempdir().unwrap();
let root = temp.path();
touch(root, &["rec-001"]);
let map = read(root);
assert_eq!(map.records["rec-001"].count, 1);
touch(root, &["rec-001"]);
let map = read(root);
assert_eq!(map.records["rec-001"].count, 2);
touch(root, &["rec-001", "rec-001"]);
let map = read(root);
assert_eq!(map.records["rec-001"].count, 4);
}
#[test]
fn read_returns_empty_for_missing_file() {
let temp = tempdir().unwrap();
let map = read(temp.path());
assert!(map.records.is_empty());
assert_eq!(map.schema_version, "");
}
#[test]
fn read_returns_empty_for_corrupt_file() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(tracker_path(root), "not valid json {{{").unwrap();
let map = read(root);
assert!(map.records.is_empty());
}
#[test]
fn age_days_computes_correctly() {
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let thirty_days_ago = now_secs - 30 * 86400;
let ts = unix_secs_to_iso8601(thirty_days_ago);
let entry = ReferenceEntry {
last_referenced_at: ts,
count: 1,
};
let days = age_days(&entry).unwrap();
assert_eq!(days, 30);
}
#[test]
fn age_days_returns_none_for_invalid_timestamp() {
let entry = ReferenceEntry {
last_referenced_at: "not-a-timestamp".to_string(),
count: 1,
};
assert_eq!(age_days(&entry), None);
let entry2 = ReferenceEntry {
last_referenced_at: "2026-13-01T00:00:00Z".to_string(), count: 1,
};
assert_eq!(age_days(&entry2), None);
}
#[test]
fn staleness_penalty_curve() {
assert_eq!(staleness_penalty(None), 0);
assert_eq!(staleness_penalty(Some(0)), 4);
assert_eq!(staleness_penalty(Some(3)), 4);
assert_eq!(staleness_penalty(Some(4)), 2);
assert_eq!(staleness_penalty(Some(7)), 2);
assert_eq!(staleness_penalty(Some(8)), 0);
assert_eq!(staleness_penalty(Some(14)), 0);
assert_eq!(staleness_penalty(Some(15)), -2);
assert_eq!(staleness_penalty(Some(30)), -2);
assert_eq!(staleness_penalty(Some(31)), -4);
assert_eq!(staleness_penalty(Some(60)), -4);
assert_eq!(staleness_penalty(Some(61)), -6);
assert_eq!(staleness_penalty(Some(90)), -6);
assert_eq!(staleness_penalty(Some(91)), -8);
assert_eq!(staleness_penalty(Some(365)), -8);
}
#[test]
fn iso8601_roundtrip() {
let secs: u64 = 1_715_000_000; let formatted = unix_secs_to_iso8601(secs);
let parsed = parse_iso8601_to_unix_secs(&formatted).unwrap();
assert_eq!(parsed, secs);
}
}