1use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
15
16use chrono::{DateTime, TimeDelta, Utc};
17use serde::{Deserialize, Serialize};
18
19use crate::diag;
20
21const CACHE_VERSION: u32 = 1;
24
25const MAX_AGE_DAYS: i64 = 7;
27
28const MAX_ENTRIES: usize = 500;
30
31#[derive(Debug, Clone)]
33pub struct CacheEntry<T> {
34 pub value: T,
35 pub fetched_at: DateTime<Utc>,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CachePut {
43 Stored,
44 Stale,
45 Disabled,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
51struct StoredEntry {
52 fetched_at: DateTime<Utc>,
53 value: serde_json::Value,
54}
55
56#[derive(Debug, Serialize, Deserialize)]
57struct CacheFile {
58 #[serde(default)]
60 version: u32,
61 #[serde(default)]
62 entries: HashMap<String, StoredEntry>,
63}
64
65fn default_cache_path() -> PathBuf {
68 let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
69 base.join("forgetop").join("cache.json")
70}
71
72#[cfg(unix)]
75async fn restrict_to_owner(path: &Path) -> std::io::Result<()> {
76 use std::os::unix::fs::PermissionsExt;
77 tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await
78}
79
80#[cfg(not(unix))]
81async fn restrict_to_owner(path: &Path) -> std::io::Result<()> {
82 tokio::fs::metadata(path).await.map(|_| ())
83}
84
85fn with_extension(path: &Path, ext: &str) -> PathBuf {
86 let mut p = path.to_path_buf();
87 p.set_extension(ext);
88 p
89}
90
91pub struct CacheStore {
93 path: Option<PathBuf>,
95 entries: RwLock<HashMap<String, StoredEntry>>,
96 dirty: AtomicBool,
97}
98
99impl CacheStore {
100 pub fn new(path: impl Into<PathBuf>) -> Self {
101 Self { path: Some(path.into()), entries: RwLock::new(HashMap::new()), dirty: AtomicBool::new(false) }
102 }
103
104 pub fn at_default_path() -> Self {
105 Self::new(default_cache_path())
106 }
107
108 pub fn disabled() -> Self {
111 Self { path: None, entries: RwLock::new(HashMap::new()), dirty: AtomicBool::new(false) }
112 }
113
114 fn read_entries(&self) -> RwLockReadGuard<'_, HashMap<String, StoredEntry>> {
117 self.entries.read().unwrap_or_else(|poisoned| poisoned.into_inner())
118 }
119
120 fn write_entries(&self) -> RwLockWriteGuard<'_, HashMap<String, StoredEntry>> {
121 self.entries.write().unwrap_or_else(|poisoned| poisoned.into_inner())
122 }
123
124 pub async fn load(&self) {
127 let Some(path) = self.path.as_ref() else { return };
128
129 let bytes = match tokio::fs::read(path).await {
130 Ok(bytes) => bytes,
131 Err(err) => {
132 diag::log("cache", &format!("no cache to load from {}: {err}", path.display()));
133 return;
134 }
135 };
136 let file: CacheFile = match serde_json::from_slice(&bytes) {
137 Ok(file) => file,
138 Err(err) => {
139 diag::log("cache", &format!("discarding unreadable cache {}: {err}", path.display()));
140 return;
141 }
142 };
143 if file.version != CACHE_VERSION {
144 diag::log(
145 "cache",
146 &format!("discarding cache at version {} (want {CACHE_VERSION})", file.version),
147 );
148 return;
149 }
150
151 let entries = prune(file.entries, Utc::now());
152 *self.write_entries() = entries;
154 }
155
156 pub fn get<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<CacheEntry<T>> {
163 let stored = self.read_entries().get(key).cloned()?;
164 match serde_json::from_value(stored.value) {
165 Ok(value) => Some(CacheEntry { value, fetched_at: stored.fetched_at }),
166 Err(err) => {
167 diag::log("cache", &format!("dropping unusable entry {key}: {err}"));
168 None
169 }
170 }
171 }
172
173 pub fn put<T: Serialize>(&self, key: &str, value: &T, fetched_at: DateTime<Utc>) -> CachePut {
180 if self.path.is_none() {
181 return CachePut::Disabled;
182 }
183 let value = match serde_json::to_value(value) {
184 Ok(value) => value,
185 Err(err) => {
186 diag::log("cache", &format!("not caching {key}: {err}"));
187 return CachePut::Disabled;
191 }
192 };
193
194 {
195 let mut entries = self.write_entries();
196 if entries.get(key).is_some_and(|existing| existing.fetched_at >= fetched_at) {
197 return CachePut::Stale;
198 }
199 entries.insert(key.to_string(), StoredEntry { fetched_at, value });
200 }
201 self.dirty.store(true, Ordering::Relaxed);
202 CachePut::Stored
203 }
204
205 pub fn rewrite<T: Serialize + serde::de::DeserializeOwned>(&self, key: &str, edit: impl FnOnce(T) -> T) -> bool {
217 if self.path.is_none() {
218 return false;
219 }
220 let Some(entry) = self.get::<T>(key) else { return false };
221 let value = match serde_json::to_value(edit(entry.value)) {
222 Ok(value) => value,
223 Err(err) => {
224 diag::log("cache", &format!("not rewriting {key}: {err}"));
225 return false;
226 }
227 };
228
229 {
230 let mut entries = self.write_entries();
231 match entries.get_mut(key) {
232 Some(stored) if stored.fetched_at == entry.fetched_at => stored.value = value,
233 _ => return false,
234 }
235 }
236 self.dirty.store(true, Ordering::Relaxed);
237 true
238 }
239
240 pub async fn flush(&self) {
243 let Some(path) = self.path.as_ref() else { return };
244 if !self.dirty.load(Ordering::Relaxed) {
245 return;
246 }
247
248 let json = {
250 let entries = self.read_entries().clone();
251 match serde_json::to_vec(&CacheFile { version: CACHE_VERSION, entries }) {
252 Ok(json) => json,
253 Err(err) => {
254 diag::log("cache", &format!("cannot serialize cache: {err}"));
255 return;
256 }
257 }
258 };
259
260 if let Some(dir) = path.parent() {
261 if let Err(err) = tokio::fs::create_dir_all(dir).await {
262 diag::log("cache", &format!("cannot create {}: {err}", dir.display()));
263 return;
264 }
265 }
266 let tmp = with_extension(path, "tmp");
268 if let Err(err) = tokio::fs::write(&tmp, &json).await {
269 diag::log("cache", &format!("cannot write {}: {err}", tmp.display()));
270 return;
271 }
272 if let Err(err) = restrict_to_owner(&tmp).await {
276 diag::log("cache", &format!("cannot secure {}: {err}", tmp.display()));
277 let _ = tokio::fs::remove_file(&tmp).await;
278 return;
279 }
280 if let Err(err) = tokio::fs::rename(&tmp, path).await {
281 diag::log("cache", &format!("cannot replace {}: {err}", path.display()));
282 return;
283 }
284 self.dirty.store(false, Ordering::Relaxed);
285 }
286}
287
288fn prune(entries: HashMap<String, StoredEntry>, now: DateTime<Utc>) -> HashMap<String, StoredEntry> {
290 let cutoff = now - TimeDelta::days(MAX_AGE_DAYS);
291 let mut kept: Vec<(String, StoredEntry)> = entries
296 .into_iter()
297 .filter(|(_, entry)| entry.fetched_at > cutoff && entry.fetched_at <= now)
298 .collect();
299 if kept.len() > MAX_ENTRIES {
300 kept.sort_by_key(|(_, entry)| std::cmp::Reverse(entry.fetched_at));
301 kept.truncate(MAX_ENTRIES);
302 }
303 kept.into_iter().collect()
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311 struct Item {
312 id: u32,
313 title: String,
314 }
315
316 fn item(id: u32) -> Item {
317 Item { id, title: format!("item {id}") }
318 }
319
320 #[test]
321 fn a_put_value_comes_back_with_its_timestamp() {
322 let dir = tempfile::tempdir().expect("tempdir");
323 let store = CacheStore::new(dir.path().join("cache.json"));
324 let at = Utc::now();
325
326 assert_eq!(store.put("prs", &item(1), at), CachePut::Stored);
327
328 let entry = store.get::<Item>("prs").expect("hit");
329 assert_eq!(entry.value, item(1));
330 assert_eq!(entry.fetched_at, at);
331 assert!(store.get::<Item>("absent").is_none());
332 }
333
334 #[test]
337 fn an_older_put_cannot_overwrite_a_newer_entry() {
338 let dir = tempfile::tempdir().expect("tempdir");
339 let store = CacheStore::new(dir.path().join("cache.json"));
340 let newer = Utc::now();
341 let older = newer - TimeDelta::seconds(30);
342
343 assert_eq!(store.put("prs", &item(2), newer), CachePut::Stored);
344 assert_eq!(store.put("prs", &item(1), older), CachePut::Stale, "stale write must be refused");
345
346 let entry = store.get::<Item>("prs").expect("hit");
347 assert_eq!(entry.value, item(2), "the newer value must survive");
348 assert_eq!(entry.fetched_at, newer);
349 }
350
351 #[test]
355 fn rewrite_edits_an_entry_in_place_without_redating_it() {
356 let dir = tempfile::tempdir().expect("tempdir");
357 let store = CacheStore::new(dir.path().join("cache.json"));
358 let at = Utc::now() - TimeDelta::hours(3);
359 store.put("prs", &vec![item(1), item(2)], at);
360
361 let wrote = store.rewrite::<Vec<Item>>("prs", |rows| rows.into_iter().filter(|i| i.id != 1).collect());
362
363 assert!(wrote);
364 let entry = store.get::<Vec<Item>>("prs").expect("hit");
365 assert_eq!(entry.value, vec![item(2)]);
366 assert_eq!(entry.fetched_at, at, "a three-hour-old list must not read as current");
367 }
368
369 #[test]
370 fn rewrite_is_a_no_op_on_a_key_that_is_not_there() {
371 let dir = tempfile::tempdir().expect("tempdir");
372 let store = CacheStore::new(dir.path().join("cache.json"));
373
374 assert!(!store.rewrite::<Vec<Item>>("absent", |rows| rows));
375 assert!(store.get::<Vec<Item>>("absent").is_none());
376 }
377
378 #[test]
381 fn rewrite_yields_to_a_fetch_that_landed_underneath_it() {
382 let dir = tempfile::tempdir().expect("tempdir");
383 let store = CacheStore::new(dir.path().join("cache.json"));
384 let at = Utc::now() - TimeDelta::hours(3);
385 store.put("prs", &vec![item(1)], at);
386
387 let wrote = store.rewrite::<Vec<Item>>("prs", |rows| {
388 store.put("prs", &vec![item(7)], Utc::now());
390 rows.into_iter().filter(|i| i.id != 1).collect()
391 });
392
393 assert!(!wrote, "the edit must not overwrite the fresher entry");
394 assert_eq!(store.get::<Vec<Item>>("prs").expect("hit").value, vec![item(7)]);
395 }
396
397 #[test]
398 fn an_equal_timestamp_put_is_refused_too() {
399 let dir = tempfile::tempdir().expect("tempdir");
400 let store = CacheStore::new(dir.path().join("cache.json"));
401 let at = Utc::now();
402
403 assert_eq!(store.put("prs", &item(2), at), CachePut::Stored);
404 assert_eq!(store.put("prs", &item(9), at), CachePut::Stale);
405 assert_eq!(store.get::<Item>("prs").expect("hit").value, item(2));
406 }
407
408 #[test]
409 fn a_shape_change_reads_as_a_miss_rather_than_a_panic() {
410 let dir = tempfile::tempdir().expect("tempdir");
411 let store = CacheStore::new(dir.path().join("cache.json"));
412 store.put("prs", &item(1), Utc::now());
413
414 assert!(store.get::<Vec<String>>("prs").is_none());
415 assert!(store.get::<Item>("prs").is_some());
417 }
418
419 #[test]
422 fn a_value_that_cannot_be_serialized_is_not_reported_as_stale() {
423 let dir = tempfile::tempdir().expect("tempdir");
424 let store = CacheStore::new(dir.path().join("cache.json"));
425 let unserializable: HashMap<(u8, u8), u8> = HashMap::from([((1, 2), 3)]);
427
428 assert_eq!(store.put("prs", &unserializable, Utc::now()), CachePut::Disabled);
429 assert!(store.get::<Item>("prs").is_none(), "nothing was stored");
430 }
431
432 #[tokio::test]
433 async fn a_corrupt_file_loads_as_empty() {
434 let dir = tempfile::tempdir().expect("tempdir");
435 let path = dir.path().join("cache.json");
436 std::fs::write(&path, b"{not json").expect("seed");
437
438 let store = CacheStore::new(&path);
439 store.load().await;
440
441 assert!(store.get::<Item>("prs").is_none());
442 }
443
444 #[tokio::test]
445 async fn a_file_from_another_version_is_discarded_wholesale() {
446 let dir = tempfile::tempdir().expect("tempdir");
447 let path = dir.path().join("cache.json");
448 let seeded = format!(
449 r#"{{"version":{},"entries":{{"prs":{{"fetched_at":"{}","value":{{"id":1,"title":"item 1"}}}}}}}}"#,
450 CACHE_VERSION + 1,
451 Utc::now().to_rfc3339()
452 );
453 std::fs::write(&path, seeded).expect("seed");
454
455 let store = CacheStore::new(&path);
456 store.load().await;
457
458 assert!(store.get::<Item>("prs").is_none());
459 }
460
461 #[tokio::test]
462 async fn a_missing_file_loads_as_empty() {
463 let dir = tempfile::tempdir().expect("tempdir");
464 let store = CacheStore::new(dir.path().join("cache.json"));
465
466 store.load().await;
467
468 assert!(store.get::<Item>("prs").is_none());
469 }
470
471 #[tokio::test]
472 async fn entries_survive_a_flush_into_a_fresh_store() {
473 let dir = tempfile::tempdir().expect("tempdir");
474 let path = dir.path().join("cache.json");
475 let at = Utc::now();
476
477 let store = CacheStore::new(&path);
478 store.put("prs", &item(7), at);
479 store.flush().await;
480
481 let reopened = CacheStore::new(&path);
482 reopened.load().await;
483
484 let entry = reopened.get::<Item>("prs").expect("hit");
485 assert_eq!(entry.value, item(7));
486 assert_eq!(entry.fetched_at, at);
487 assert!(!with_extension(&path, "tmp").exists());
489 }
490
491 #[cfg(unix)]
494 #[tokio::test]
495 async fn a_flushed_cache_is_readable_only_by_its_owner() {
496 use std::os::unix::fs::PermissionsExt;
497
498 let dir = tempfile::tempdir().expect("tempdir");
499 let path = dir.path().join("cache.json");
500 let store = CacheStore::new(&path);
501 store.put("prs", &item(7), Utc::now());
502 store.flush().await;
503
504 let mode = std::fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
505 assert_eq!(mode, 0o600, "cache.json had mode {mode:o}");
506 }
507
508 #[tokio::test]
509 async fn load_drops_entries_past_the_age_bound() {
510 let dir = tempfile::tempdir().expect("tempdir");
511 let path = dir.path().join("cache.json");
512 let now = Utc::now();
513
514 let store = CacheStore::new(&path);
515 store.put("fresh", &item(1), now - TimeDelta::days(MAX_AGE_DAYS - 1));
516 store.put("stale", &item(2), now - TimeDelta::days(MAX_AGE_DAYS + 1));
517 store.flush().await;
518
519 let reopened = CacheStore::new(&path);
520 reopened.load().await;
521
522 assert!(reopened.get::<Item>("fresh").is_some());
523 assert!(reopened.get::<Item>("stale").is_none(), "expired entry must not hydrate");
524 }
525
526 #[test]
529 fn prune_drops_future_dated_entries() {
530 let now = Utc::now();
531 let entries: HashMap<String, StoredEntry> = [
532 ("sane".to_string(), StoredEntry { fetched_at: now - TimeDelta::hours(1), value: serde_json::json!(1) }),
533 ("skewed".to_string(), StoredEntry { fetched_at: now + TimeDelta::days(400), value: serde_json::json!(2) }),
534 ]
535 .into_iter()
536 .collect();
537
538 let kept = prune(entries, now);
539
540 assert!(kept.contains_key("sane"));
541 assert!(!kept.contains_key("skewed"), "a future-dated entry would wedge its key forever");
542 }
543
544 #[test]
545 fn prune_keeps_the_most_recent_entries_within_the_count_bound() {
546 let now = Utc::now();
547 let entries: HashMap<String, StoredEntry> = (0..MAX_ENTRIES + 10)
548 .map(|i| {
549 let fetched_at = now - TimeDelta::seconds(i as i64);
550 let value = serde_json::json!({ "id": i });
551 (format!("k{i}"), StoredEntry { fetched_at, value })
552 })
553 .collect();
554
555 let kept = prune(entries, now);
556
557 assert_eq!(kept.len(), MAX_ENTRIES);
558 assert!(kept.contains_key("k0"), "newest must survive");
559 assert!(!kept.contains_key(&format!("k{}", MAX_ENTRIES + 9)), "oldest must be dropped");
560 }
561
562 #[tokio::test]
563 async fn a_disabled_store_never_hits_and_never_writes() {
564 let dir = tempfile::tempdir().expect("tempdir");
565 let store = CacheStore::disabled();
566
567 assert_eq!(
568 store.put("prs", &item(1), Utc::now()),
569 CachePut::Disabled,
570 "a disabled store is not refusing the write as stale"
571 );
572 assert!(store.get::<Item>("prs").is_none());
573 store.load().await;
574 store.flush().await;
575
576 assert!(store.get::<Item>("prs").is_none());
577 let written = std::fs::read_dir(dir.path()).expect("read dir").count();
578 assert_eq!(written, 0, "a disabled store must not touch the filesystem");
579 }
580}