Skip to main content

forgetop_core/
cache.rs

1//! Stale-while-revalidate cache for fetched provider data.
2//!
3//! A JSON file on disk mirrored by an in-memory map. The TUI hydrates it once at startup and
4//! paints the last known data immediately, then repaints when the network answers — the cache
5//! exists to remove the blank screen you would otherwise stare at while the first fetch runs.
6//! Serving stale data is therefore the point, not a compromise.
7//!
8//! Every failure mode here degrades to a miss or a no-op: a cache that can return an error is a
9//! cache that can break the app it was added to speed up.
10
11use 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
21/// Bumped when the on-disk shape changes. A file at any other version is discarded wholesale
22/// rather than migrated — re-fetching is cheap, a half-migrated cache is not.
23const CACHE_VERSION: u32 = 1;
24
25/// Anything a week old is worth a round trip; keeping it only grows the file we parse at startup.
26const MAX_AGE_DAYS: i64 = 7;
27
28/// A hard ceiling so a long-lived install can't turn startup hydration into a visible pause.
29const MAX_ENTRIES: usize = 500;
30
31/// A cached value plus when it was fetched.
32#[derive(Debug, Clone)]
33pub struct CacheEntry<T> {
34    pub value: T,
35    pub fetched_at: DateTime<Utc>,
36}
37
38/// What a `put` did. `Stale` is the only outcome that means the caller is holding older data
39/// than the store — `Disabled` is not a refusal, it is a cache that simply isn't storing
40/// anything, so a caller that repaints from what it just tried to write is still correct.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CachePut {
43    Stored,
44    Stale,
45    Disabled,
46}
47
48/// An entry as it lives on disk and in the map: already serialized, so one store can hold every
49/// domain type without `CacheStore` itself being generic.
50#[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    /// Defaulted so a file written before versioning reads as 0 and is discarded, not rejected.
59    #[serde(default)]
60    version: u32,
61    #[serde(default)]
62    entries: HashMap<String, StoredEntry>,
63}
64
65/// Resolves the on-disk cache path: `$XDG_CONFIG_HOME/forgetop/cache.json` (or the platform
66/// config dir), alongside `config.json`.
67fn 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/// Owner-only permissions, mirroring `diag`'s treatment of the log file. A no-op off unix,
73/// where the config directory's own ACLs are the protection.
74#[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
91/// JSON-file cache with atomic (temp + rename) writes and an in-memory mirror.
92pub struct CacheStore {
93    /// `None` marks the store disabled: nothing is read and nothing is written.
94    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    /// Non-persistent store: `get` always misses, `put` and `flush` are no-ops. Used for `--demo`
109    /// and tests — demo data is canned, so caching it would only leak between runs.
110    pub fn disabled() -> Self {
111        Self { path: None, entries: RwLock::new(HashMap::new()), dirty: AtomicBool::new(false) }
112    }
113
114    /// A poisoned lock means some other caller panicked mid-update; for a cache the worst case is
115    /// one odd entry, which is not worth taking the process down for.
116    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    /// Hydrate the in-memory map from disk. Never fails the caller: a missing, unreadable, corrupt
125    /// or version-mismatched file leaves the store empty and logs a line.
126    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        // The guard is taken after all the I/O and never held across an await.
153        *self.write_entries() = entries;
154    }
155
156    /// Read a cached value. `None` on a miss, and on a deserialize failure — a domain type whose
157    /// shape changed since the entry was written must degrade to a miss, not an error.
158    ///
159    /// There is deliberately **no read-time TTL**: an entry is returned however old it is. Callers
160    /// paint stale data on purpose and surface its age in the UI. Expiring on read would hand the
161    /// caller a blank screen again, which is the exact problem this cache exists to remove.
162    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    /// Write through, reporting what happened.
174    ///
175    /// Refuses ([`CachePut::Stale`], mutating nothing) when an entry already exists whose
176    /// `fetched_at` is newer than or equal to this one. Responses do not come back in the order
177    /// they were sent: a slow request started before a fast one can land after it, and without
178    /// this guard it would quietly overwrite fresh data with stale.
179    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                // Nothing was stored, but nothing newer is being held back either — a value this
188                // store cannot serialize is, for this key, a store that isn't storing. Reporting
189                // it as `Stale` would tell the caller its data is out of date, which it is not.
190                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    /// Edits an entry already in the store, keeping its `fetched_at`.
206    ///
207    /// [`put`](Self::put) is for "here is a fresher response", and its staleness guard compares
208    /// timestamps — so it *refuses* a write carrying the entry's own `fetched_at`, which is
209    /// exactly what editing a cached list in place has to do. This is the seam for that: dropping
210    /// rows from a list is not new data and must not be dated as if it were, or the staleness
211    /// indicator would report an hours-old list as current.
212    ///
213    /// Returns whether anything was written. A missing key is a no-op, and so is an entry that was
214    /// replaced by a real fetch while `edit` ran — that response is newer than this edit and owns
215    /// the key.
216    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    /// Persist to disk if dirty, atomically. Errors are logged, never propagated; the dirty flag
241    /// survives a failed write so the next flush retries rather than losing the entries.
242    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        // Serialize from a snapshot so no lock guard is alive across the writes below.
249        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        // Write to a temp file then rename so a crash mid-write can't corrupt the cache.
267        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        // Tighten before the rename, so the file is never world-readable even briefly. This
273        // holds private repository data — PR titles, branch names, diff hunks — so it gets the
274        // same 0600 the diagnostics log gets, not the 0644 a default umask would leave.
275        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
288/// Apply the storage bounds at hydration time, where the cost is paid once per run.
289fn prune(entries: HashMap<String, StoredEntry>, now: DateTime<Utc>) -> HashMap<String, StoredEntry> {
290    let cutoff = now - TimeDelta::days(MAX_AGE_DAYS);
291    // Future-dated entries are dropped as well as ancient ones. `put` refuses anything not
292    // strictly newer than what is stored, so an entry written under a skewed clock would
293    // otherwise refuse every honest write for that key for as long as the file survives —
294    // a detail view that silently stops updating, with nothing to age it out.
295    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    /// The invariant the whole store exists to protect: a slow request that started first must not
335    /// be able to land on top of a fresher response.
336    #[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    /// `rewrite` exists precisely because `put` refuses an equal timestamp (above). Editing a
352    /// cached list in place has to carry the entry's own `fetched_at`, so it cannot go through
353    /// `put` — and it must not bump the timestamp to get past the guard either.
354    #[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    /// A real fetch that lands while the edit is being computed owns the key: it is newer than
379    /// the edit, and re-applying the edit's view of the list would undo it.
380    #[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            // Stand in for the response arriving mid-edit.
389            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        // The entry is untouched for a caller that does know its type.
416        assert!(store.get::<Item>("prs").is_some());
417    }
418
419    /// A value that cannot be serialized stores nothing, but the store isn't holding anything
420    /// newer either — so it must not read as `Stale`, which is what tells a caller to stop.
421    #[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        // serde_json cannot represent a map with non-string keys.
426        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        // Nothing is left behind by the temp + rename.
488        assert!(!with_extension(&path, "tmp").exists());
489    }
490
491    /// The cache holds private repository data, so it must not be world-readable — the same
492    /// standard `diag` holds the log file to.
493    #[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    /// A clock-skewed entry would refuse every honest write for its key forever, so ageing it
527    /// out is the only thing that can unstick it.
528    #[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}