Skip to main content

lean_ctx/core/
graph_cache.rs

1//! Resident graph-index cache (Phase 5 of the efficiency epic).
2//!
3//! `try_load_graph_index` used to deserialize the on-disk `ProjectIndex`
4//! (read + zstd-decompress + serde parse) on *every* query that touches the
5//! graph (symbol lookups, related hints, impact). This keeps the deserialized
6//! index resident in RAM keyed by project root, invalidated by the on-disk
7//! index file's mtime so a background rebuild is picked up immediately (no TTL
8//! wait). Callers that need an owned value get a cheap in-memory clone instead
9//! of a disk round-trip.
10
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex, OnceLock};
13use std::time::SystemTime;
14
15use crate::core::graph_index::ProjectIndex;
16
17/// `(mtime, size)` fingerprint of the on-disk index file. Size pairs with mtime
18/// to catch same-second rebuilds that coarse (1–2 s) filesystem mtime would
19/// otherwise hide — cheap, no file read.
20#[derive(Clone, Copy, PartialEq, Eq, Default)]
21struct Fingerprint {
22    mtime: Option<SystemTime>,
23    size: u64,
24}
25
26struct Entry {
27    index: Arc<ProjectIndex>,
28    fingerprint: Fingerprint,
29}
30
31static CACHE: OnceLock<Mutex<HashMap<String, Entry>>> = OnceLock::new();
32
33fn cache() -> &'static Mutex<HashMap<String, Entry>> {
34    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
35}
36
37/// `(mtime, size)` of the persisted graph index file (zst preferred), if any.
38fn index_fingerprint(project_root: &str) -> Fingerprint {
39    let Some(dir) = ProjectIndex::index_dir(project_root) else {
40        return Fingerprint::default();
41    };
42    for name in ["index.json.zst", "index.json"] {
43        if let Ok(meta) = std::fs::metadata(dir.join(name)) {
44            return Fingerprint {
45                mtime: meta.modified().ok(),
46                size: meta.len(),
47            };
48        }
49    }
50    Fingerprint::default()
51}
52
53/// Returns the resident `ProjectIndex` for `project_root`, loading from disk
54/// only when absent or when the on-disk index file changed. `None` when no
55/// non-empty index exists on disk.
56pub fn get_cached(project_root: &str) -> Option<Arc<ProjectIndex>> {
57    let fingerprint = index_fingerprint(project_root);
58
59    {
60        let map = cache()
61            .lock()
62            .unwrap_or_else(std::sync::PoisonError::into_inner);
63        if let Some(entry) = map.get(project_root) {
64            if entry.fingerprint == fingerprint {
65                return Some(Arc::clone(&entry.index));
66            }
67        }
68    }
69
70    let idx = ProjectIndex::load(project_root).filter(|i| !i.files.is_empty())?;
71    let arc = Arc::new(idx);
72
73    let mut map = cache()
74        .lock()
75        .unwrap_or_else(std::sync::PoisonError::into_inner);
76    map.insert(
77        project_root.to_string(),
78        Entry {
79            index: Arc::clone(&arc),
80            fingerprint,
81        },
82    );
83    Some(arc)
84}
85
86/// Drops the cached graph index for a root (or all roots when `None`).
87pub fn invalidate(project_root: Option<&str>) {
88    let mut map = cache()
89        .lock()
90        .unwrap_or_else(std::sync::PoisonError::into_inner);
91    match project_root {
92        Some(root) => {
93            map.remove(root);
94        }
95        None => map.clear(),
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn returns_none_without_index() {
105        let tmp = tempfile::tempdir().unwrap();
106        invalidate(None);
107        assert!(get_cached(tmp.path().to_str().unwrap()).is_none());
108    }
109}