1use fs2::FileExt;
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17use std::fs::{self, File, OpenOptions};
18use std::io::Read as _;
19use std::path::{Path, PathBuf};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22pub const TRACKER_FILE_NAME: &str = "reference-tracker.json";
23const SCHEMA_VERSION: &str = "reference-tracker.v1";
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct ReferenceMap {
27 #[serde(default)]
28 pub schema_version: String,
29 #[serde(default)]
30 pub records: BTreeMap<String, ReferenceEntry>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ReferenceEntry {
35 pub last_referenced_at: String,
37 pub count: u64,
38}
39
40pub fn tracker_path(root: &Path) -> PathBuf {
42 root.join(TRACKER_FILE_NAME)
43}
44
45pub fn touch(root: &Path, record_ids: &[&str]) {
50 if record_ids.is_empty() {
51 return;
52 }
53 if let Err(err) = touch_inner(root, record_ids) {
54 eprintln!("[spool] reference tracker touch failed: {err}");
55 }
56}
57
58pub fn read(root: &Path) -> ReferenceMap {
61 let path = tracker_path(root);
62 if !path.exists() {
63 return ReferenceMap::default();
64 }
65 match fs::read_to_string(&path) {
66 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
67 Err(_) => ReferenceMap::default(),
68 }
69}
70
71pub fn age_days(entry: &ReferenceEntry) -> Option<u64> {
74 let referenced_secs = parse_iso8601_to_unix_secs(&entry.last_referenced_at)?;
75 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
76 if now_secs < referenced_secs {
77 return Some(0);
78 }
79 Some((now_secs - referenced_secs) / 86400)
80}
81
82pub fn staleness_penalty(age: Option<u64>) -> i32 {
90 match age {
91 None => 0,
92 Some(days) => match days {
93 0..=3 => 4,
94 4..=7 => 2,
95 8..=14 => 0,
96 15..=30 => -2,
97 31..=60 => -4,
98 61..=90 => -6,
99 _ => -8,
100 },
101 }
102}
103
104fn touch_inner(root: &Path, record_ids: &[&str]) -> anyhow::Result<()> {
107 fs::create_dir_all(root)
108 .map_err(|e| anyhow::anyhow!("creating tracker dir {}: {e}", root.display()))?;
109
110 let path = tracker_path(root);
111 let file = OpenOptions::new()
112 .create(true)
113 .truncate(false)
114 .read(true)
115 .write(true)
116 .open(&path)
117 .map_err(|e| anyhow::anyhow!("opening tracker {}: {e}", path.display()))?;
118 file.lock_exclusive()
119 .map_err(|e| anyhow::anyhow!("locking tracker {}: {e}", path.display()))?;
120
121 let result = (|| -> anyhow::Result<()> {
122 let mut content = String::new();
123 let mut reader =
125 File::open(&path).map_err(|e| anyhow::anyhow!("re-reading tracker: {e}"))?;
126 reader.read_to_string(&mut content).ok();
127
128 let mut map: ReferenceMap = if content.trim().is_empty() {
129 ReferenceMap::default()
130 } else {
131 serde_json::from_str(&content).unwrap_or_default()
132 };
133 map.schema_version = SCHEMA_VERSION.to_string();
134
135 let now = now_iso8601();
136 for &id in record_ids {
137 let entry = map.records.entry(id.to_string()).or_insert(ReferenceEntry {
138 last_referenced_at: now.clone(),
139 count: 0,
140 });
141 entry.last_referenced_at = now.clone();
142 entry.count += 1;
143 }
144
145 let serialized = serde_json::to_string_pretty(&map)
146 .map_err(|e| anyhow::anyhow!("serializing tracker: {e}"))?;
147 fs::write(&path, serialized)
148 .map_err(|e| anyhow::anyhow!("writing tracker {}: {e}", path.display()))?;
149 Ok(())
150 })();
151
152 let _ = FileExt::unlock(&file);
153 result
154}
155
156fn now_iso8601() -> String {
159 let secs = SystemTime::now()
160 .duration_since(UNIX_EPOCH)
161 .unwrap_or_default()
162 .as_secs();
163 unix_secs_to_iso8601(secs)
164}
165
166fn unix_secs_to_iso8601(secs: u64) -> String {
168 let days = secs / 86400;
170 let time_of_day = secs % 86400;
171 let hours = time_of_day / 3600;
172 let minutes = (time_of_day % 3600) / 60;
173 let seconds = time_of_day % 60;
174
175 let (year, month, day) = days_to_ymd(days);
176 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
177}
178
179fn days_to_ymd(days: u64) -> (u64, u64, u64) {
181 let z = days + 719468;
183 let era = z / 146097;
184 let doe = z - era * 146097;
185 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
186 let y = yoe + era * 400;
187 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
188 let mp = (5 * doy + 2) / 153;
189 let d = doy - (153 * mp + 2) / 5 + 1;
190 let m = if mp < 10 { mp + 3 } else { mp - 9 };
191 let y = if m <= 2 { y + 1 } else { y };
192 (y, m, d)
193}
194
195fn parse_iso8601_to_unix_secs(s: &str) -> Option<u64> {
198 let s = s.trim();
199 if s.len() < 20 {
201 return None;
202 }
203 let year: u64 = s.get(0..4)?.parse().ok()?;
204 if s.as_bytes().get(4)? != &b'-' {
205 return None;
206 }
207 let month: u64 = s.get(5..7)?.parse().ok()?;
208 if s.as_bytes().get(7)? != &b'-' {
209 return None;
210 }
211 let day: u64 = s.get(8..10)?.parse().ok()?;
212 if s.as_bytes().get(10)? != &b'T' {
213 return None;
214 }
215 let hour: u64 = s.get(11..13)?.parse().ok()?;
216 if s.as_bytes().get(13)? != &b':' {
217 return None;
218 }
219 let min: u64 = s.get(14..16)?.parse().ok()?;
220 if s.as_bytes().get(16)? != &b':' {
221 return None;
222 }
223 let sec: u64 = s.get(17..19)?.parse().ok()?;
224
225 if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || min > 59 || sec > 59 {
227 return None;
228 }
229
230 let tz_part = s.get(19..)?;
232 if tz_part != "Z" && tz_part != "+00:00" && tz_part != "-00:00" {
233 return None;
234 }
235
236 let days = ymd_to_days(year, month, day)?;
237 Some(days * 86400 + hour * 3600 + min * 60 + sec)
238}
239
240fn ymd_to_days(year: u64, month: u64, day: u64) -> Option<u64> {
243 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
244 return None;
245 }
246 let y = if month <= 2 { year - 1 } else { year };
248 let m = if month <= 2 { month + 9 } else { month - 3 };
249 let era = y / 400;
250 let yoe = y - era * 400;
251 let doy = (153 * m + 2) / 5 + day - 1;
252 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
253 let days = era * 146097 + doe;
254 days.checked_sub(719468)
256}
257
258#[cfg(test)]
259pub mod tests {
260 use super::*;
261 use tempfile::tempdir;
262
263 pub fn unix_secs_to_iso8601_for_test(secs: u64) -> String {
267 super::unix_secs_to_iso8601(secs)
268 }
269
270 #[test]
271 fn touch_creates_file_when_absent() {
272 let temp = tempdir().unwrap();
273 let root = temp.path();
274 assert!(!tracker_path(root).exists());
275
276 touch(root, &["rec-001", "rec-002"]);
277
278 assert!(tracker_path(root).exists());
279 let map = read(root);
280 assert_eq!(map.schema_version, SCHEMA_VERSION);
281 assert_eq!(map.records.len(), 2);
282 assert!(map.records.contains_key("rec-001"));
283 assert!(map.records.contains_key("rec-002"));
284 }
285
286 #[test]
287 fn touch_updates_existing_entry() {
288 let temp = tempdir().unwrap();
289 let root = temp.path();
290
291 let mut map = ReferenceMap {
293 schema_version: SCHEMA_VERSION.to_string(),
294 records: BTreeMap::new(),
295 };
296 map.records.insert(
297 "rec-001".to_string(),
298 ReferenceEntry {
299 last_referenced_at: "2020-01-01T00:00:00Z".to_string(),
300 count: 5,
301 },
302 );
303 fs::create_dir_all(root).unwrap();
304 fs::write(
305 tracker_path(root),
306 serde_json::to_string_pretty(&map).unwrap(),
307 )
308 .unwrap();
309
310 touch(root, &["rec-001"]);
311
312 let updated = read(root);
313 let entry = updated.records.get("rec-001").unwrap();
314 assert_ne!(entry.last_referenced_at, "2020-01-01T00:00:00Z");
316 assert!(entry.last_referenced_at.ends_with('Z'));
317 }
318
319 #[test]
320 fn touch_increments_count() {
321 let temp = tempdir().unwrap();
322 let root = temp.path();
323
324 touch(root, &["rec-001"]);
325 let map = read(root);
326 assert_eq!(map.records["rec-001"].count, 1);
327
328 touch(root, &["rec-001"]);
329 let map = read(root);
330 assert_eq!(map.records["rec-001"].count, 2);
331
332 touch(root, &["rec-001", "rec-001"]);
333 let map = read(root);
334 assert_eq!(map.records["rec-001"].count, 4);
336 }
337
338 #[test]
339 fn read_returns_empty_for_missing_file() {
340 let temp = tempdir().unwrap();
341 let map = read(temp.path());
342 assert!(map.records.is_empty());
343 assert_eq!(map.schema_version, "");
344 }
345
346 #[test]
347 fn read_returns_empty_for_corrupt_file() {
348 let temp = tempdir().unwrap();
349 let root = temp.path();
350 fs::write(tracker_path(root), "not valid json {{{").unwrap();
351 let map = read(root);
352 assert!(map.records.is_empty());
353 }
354
355 #[test]
356 fn age_days_computes_correctly() {
357 let now_secs = SystemTime::now()
359 .duration_since(UNIX_EPOCH)
360 .unwrap()
361 .as_secs();
362 let thirty_days_ago = now_secs - 30 * 86400;
363 let ts = unix_secs_to_iso8601(thirty_days_ago);
364 let entry = ReferenceEntry {
365 last_referenced_at: ts,
366 count: 1,
367 };
368 let days = age_days(&entry).unwrap();
369 assert_eq!(days, 30);
370 }
371
372 #[test]
373 fn age_days_returns_none_for_invalid_timestamp() {
374 let entry = ReferenceEntry {
375 last_referenced_at: "not-a-timestamp".to_string(),
376 count: 1,
377 };
378 assert_eq!(age_days(&entry), None);
379
380 let entry2 = ReferenceEntry {
381 last_referenced_at: "2026-13-01T00:00:00Z".to_string(), count: 1,
383 };
384 assert_eq!(age_days(&entry2), None);
385 }
386
387 #[test]
388 fn staleness_penalty_curve() {
389 assert_eq!(staleness_penalty(None), 0);
390 assert_eq!(staleness_penalty(Some(0)), 4);
391 assert_eq!(staleness_penalty(Some(3)), 4);
392 assert_eq!(staleness_penalty(Some(4)), 2);
393 assert_eq!(staleness_penalty(Some(7)), 2);
394 assert_eq!(staleness_penalty(Some(8)), 0);
395 assert_eq!(staleness_penalty(Some(14)), 0);
396 assert_eq!(staleness_penalty(Some(15)), -2);
397 assert_eq!(staleness_penalty(Some(30)), -2);
398 assert_eq!(staleness_penalty(Some(31)), -4);
399 assert_eq!(staleness_penalty(Some(60)), -4);
400 assert_eq!(staleness_penalty(Some(61)), -6);
401 assert_eq!(staleness_penalty(Some(90)), -6);
402 assert_eq!(staleness_penalty(Some(91)), -8);
403 assert_eq!(staleness_penalty(Some(365)), -8);
404 }
405
406 #[test]
407 fn iso8601_roundtrip() {
408 let secs: u64 = 1_715_000_000; let formatted = unix_secs_to_iso8601(secs);
411 let parsed = parse_iso8601_to_unix_secs(&formatted).unwrap();
412 assert_eq!(parsed, secs);
413 }
414}