Skip to main content

chainsaw/
cache.rs

1//! Three-tier disk cache for dependency graphs.
2//!
3//! Tier 1 caches the entire graph with file mtimes for a stat-only fast path.
4//! Tier 1.5 incrementally re-parses only changed files when imports are stable.
5//! Tier 2 caches per-file parse and resolve results so single-file edits skip
6//! the resolver entirely.
7
8use rayon::prelude::*;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::thread;
15use std::time::SystemTime;
16
17use crate::graph::ModuleGraph;
18use crate::lang::ParseResult;
19
20const CACHE_FILE: &str = ".chainsaw.cache";
21const CACHE_VERSION: u32 = 8;
22// 16-byte header: magic (4) + version (4) + graph_len (8)
23const CACHE_MAGIC: u32 = 0x4348_5357; // "CHSW"
24const HEADER_SIZE: usize = 16;
25
26pub fn cache_path(root: &Path) -> PathBuf {
27    root.join(CACHE_FILE)
28}
29
30fn mtime_of(meta: &fs::Metadata) -> Option<u128> {
31    meta.modified()
32        .ok()
33        .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
34        .map(|d| d.as_nanos())
35}
36
37// --- Per-file parse cache (tier 2) ---
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40struct CachedParse {
41    mtime_nanos: u128,
42    size: u64,
43    result: ParseResult,
44    resolved_paths: Vec<Option<PathBuf>>,
45}
46
47// --- Whole-graph cache (tier 1) ---
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50struct CachedMtime {
51    mtime_nanos: u128,
52    size: u64,
53}
54
55#[derive(Debug, Serialize, Deserialize)]
56struct CachedGraph {
57    entry: PathBuf,
58    graph: ModuleGraph,
59    file_mtimes: HashMap<PathBuf, CachedMtime>,
60    unresolved_specifiers: Vec<String>,
61    unresolvable_dynamic: usize,
62    /// Per-file counts of unresolvable dynamic imports.
63    unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
64    /// Lockfile mtimes — if unchanged, skip re-resolving unresolved specifiers.
65    dep_sentinels: Vec<(PathBuf, u128)>,
66}
67
68const LOCKFILES: &[&str] = &[
69    "package-lock.json",
70    "pnpm-lock.yaml",
71    "yarn.lock",
72    "bun.lockb",
73    "poetry.lock",
74    "Pipfile.lock",
75    "uv.lock",
76    "requirements.txt",
77];
78
79/// Find lockfile sentinels by walking up from `root` until a directory
80/// containing a lockfile is found. This handles workspace layouts where
81/// the lockfile lives at the workspace root, not the package root.
82fn find_dep_sentinels(root: &Path) -> Vec<(PathBuf, u128)> {
83    let mut dir = root.to_path_buf();
84    loop {
85        let sentinels: Vec<(PathBuf, u128)> = LOCKFILES
86            .iter()
87            .filter_map(|name| {
88                let path = dir.join(name);
89                let meta = fs::metadata(&path).ok()?;
90                let mtime = mtime_of(&meta)?;
91                Some((path, mtime))
92            })
93            .collect();
94        if !sentinels.is_empty() {
95            return sentinels;
96        }
97        if !dir.pop() {
98            return Vec::new();
99        }
100    }
101}
102
103#[derive(Debug)]
104#[non_exhaustive]
105pub enum GraphCacheResult {
106    /// All files unchanged — graph is valid.
107    Hit {
108        graph: ModuleGraph,
109        unresolvable_dynamic: usize,
110        unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
111        unresolved_specifiers: Vec<String>,
112        /// True if the graph is valid but sentinel mtimes need updating.
113        needs_resave: bool,
114    },
115    /// Some files have different mtimes — incremental update possible.
116    Stale {
117        graph: ModuleGraph,
118        unresolvable_dynamic: usize,
119        unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
120        changed_files: Vec<PathBuf>,
121    },
122    /// Cache miss — wrong entry, no cache, file deleted, or new imports resolve.
123    Miss,
124}
125
126/// Handle for a background cache write. Joins the write thread on drop
127/// to ensure the cache file is fully written before process exit.
128#[derive(Debug)]
129#[repr(transparent)]
130pub struct CacheWriteHandle(Option<thread::JoinHandle<()>>);
131
132impl CacheWriteHandle {
133    pub const fn none() -> Self {
134        Self(None)
135    }
136
137    /// Block until the background cache write completes.
138    ///
139    /// This is equivalent to dropping the handle, but makes the intent explicit.
140    pub fn join(mut self) {
141        if let Some(handle) = self.0.take() {
142            let _ = handle.join();
143        }
144    }
145}
146
147impl Drop for CacheWriteHandle {
148    fn drop(&mut self) {
149        if let Some(handle) = self.0.take() {
150            let _ = handle.join();
151        }
152    }
153}
154
155#[derive(Debug)]
156pub struct ParseCache {
157    entries: HashMap<PathBuf, CachedParse>,
158    deferred_parse_data: Option<Vec<u8>>,
159    cached_graph: Option<CachedGraph>,
160    /// Preserved from Stale result for incremental save.
161    stale_file_mtimes: Option<HashMap<PathBuf, CachedMtime>>,
162    stale_unresolved: Option<Vec<String>>,
163}
164
165impl Default for ParseCache {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl ParseCache {
172    pub fn new() -> Self {
173        Self {
174            entries: HashMap::new(),
175            deferred_parse_data: None,
176            cached_graph: None,
177            stale_file_mtimes: None,
178            stale_unresolved: None,
179        }
180    }
181
182    /// Load cache from disk. The graph cache is deserialized immediately;
183    /// parse entries are deferred until first access (saves ~2.5ms on cache hit).
184    #[allow(clippy::cast_possible_truncation)]
185    pub fn load(root: &Path) -> Self {
186        let path = cache_path(root);
187        let Ok(data) = fs::read(&path) else {
188            return Self::new();
189        };
190        if data.len() < HEADER_SIZE {
191            return Self::new();
192        }
193        let magic = u32::from_le_bytes(data[0..4].try_into().unwrap());
194        let version = u32::from_le_bytes(data[4..8].try_into().unwrap());
195        if magic != CACHE_MAGIC || version != CACHE_VERSION {
196            return Self::new();
197        }
198        let graph_len = u64::from_le_bytes(data[8..16].try_into().unwrap()) as usize;
199        let graph_end = HEADER_SIZE + graph_len;
200        if data.len() < graph_end {
201            return Self::new();
202        }
203
204        let cached_graph: Option<CachedGraph> =
205            bitcode::deserialize(&data[HEADER_SIZE..graph_end]).ok();
206
207        let deferred = if data.len() > graph_end {
208            Some(data[graph_end..].to_vec())
209        } else {
210            None
211        };
212
213        Self {
214            entries: HashMap::new(),
215            deferred_parse_data: deferred,
216            cached_graph,
217            stale_file_mtimes: None,
218            stale_unresolved: None,
219        }
220    }
221
222    fn ensure_entries(&mut self) {
223        if let Some(bytes) = self.deferred_parse_data.take() {
224            self.entries = bitcode::deserialize(&bytes).unwrap_or_default();
225        }
226    }
227
228    /// Try to load the cached graph (tier 1).
229    ///
230    /// Returns `Hit` if all files are unchanged, `Stale` if some files changed
231    /// (incremental update possible), or `Miss` for entry mismatch/deleted files/
232    /// newly-resolved imports.
233    pub fn try_load_graph(
234        &mut self,
235        entry: &Path,
236        resolve_fn: &(dyn Fn(&str) -> bool + Sync),
237    ) -> GraphCacheResult {
238        let cached = match self.cached_graph.as_ref() {
239            Some(c) if c.entry == entry => c,
240            _ => return GraphCacheResult::Miss,
241        };
242
243        // Check all file mtimes in parallel — collect changed files.
244        // If any file is missing (deleted), return Miss.
245        let any_missing = AtomicBool::new(false);
246        let changed_files: Vec<PathBuf> = cached
247            .file_mtimes
248            .par_iter()
249            .filter_map(|(path, saved)| {
250                if let Ok(meta) = fs::metadata(path) {
251                    let mtime = mtime_of(&meta)?;
252                    if mtime != saved.mtime_nanos || meta.len() != saved.size {
253                        Some(path.clone())
254                    } else {
255                        None
256                    }
257                } else {
258                    any_missing.store(true, Ordering::Relaxed);
259                    None
260                }
261            })
262            .collect();
263
264        if any_missing.load(Ordering::Relaxed) {
265            return GraphCacheResult::Miss;
266        }
267
268        // Check if any previously-unresolved specifier now resolves.
269        // Optimization: if we have lockfile sentinels and none changed, skip the
270        // expensive re-resolution check. A new `npm install` / `pip install` would
271        // modify the lockfile, triggering the full check.
272        let sentinels_unchanged = !cached.dep_sentinels.is_empty()
273            && cached.dep_sentinels.iter().all(|(path, saved_mtime)| {
274                fs::metadata(path)
275                    .ok()
276                    .and_then(|m| mtime_of(&m))
277                    .is_some_and(|t| t == *saved_mtime)
278            });
279
280        if !sentinels_unchanged {
281            let any_resolves = cached
282                .unresolved_specifiers
283                .par_iter()
284                .any(|spec| resolve_fn(spec));
285            if any_resolves {
286                return GraphCacheResult::Miss;
287            }
288        }
289
290        if changed_files.is_empty() {
291            let cached = self.cached_graph.take().unwrap();
292            return GraphCacheResult::Hit {
293                graph: cached.graph,
294                unresolvable_dynamic: cached.unresolvable_dynamic,
295                unresolvable_dynamic_files: cached.unresolvable_dynamic_files,
296                unresolved_specifiers: cached.unresolved_specifiers,
297                needs_resave: !sentinels_unchanged,
298            };
299        }
300
301        // Files changed — extract graph and preserve mtimes for incremental save
302        let cached = self.cached_graph.take().unwrap();
303        self.stale_file_mtimes = Some(cached.file_mtimes);
304        self.stale_unresolved = Some(cached.unresolved_specifiers);
305        GraphCacheResult::Stale {
306            graph: cached.graph,
307            unresolvable_dynamic: cached.unresolvable_dynamic,
308            unresolvable_dynamic_files: cached.unresolvable_dynamic_files,
309            changed_files,
310        }
311    }
312
313    /// Get the cached parse result for a file WITHOUT verifying its mtime.
314    /// Used by incremental update to compare old imports against new parse results.
315    pub fn lookup_unchecked(&mut self, path: &Path) -> Option<&ParseResult> {
316        self.ensure_entries();
317        self.entries.get(path).map(|e| &e.result)
318    }
319
320    /// Save after incremental update. Uses the preserved `file_mtimes` from the
321    /// Stale result, updating only the changed files' mtimes instead of
322    /// re-statting every file. Serialization and disk write happen on a
323    /// background thread.
324    pub fn save_incremental(
325        &mut self,
326        root: &Path,
327        entry: &Path,
328        graph: &ModuleGraph,
329        changed_files: &[PathBuf],
330        unresolvable_dynamic: usize,
331        unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
332    ) -> CacheWriteHandle {
333        let Some(mut file_mtimes) = self.stale_file_mtimes.take() else {
334            return CacheWriteHandle::none();
335        };
336        let unresolved_specifiers = self.stale_unresolved.take().unwrap_or_default();
337
338        // Update only changed files' mtimes (cheap, typically 1-2 files)
339        for path in changed_files {
340            if let Ok(meta) = fs::metadata(path)
341                && let Some(mtime) = mtime_of(&meta)
342                && let Some(saved) = file_mtimes.get_mut(path)
343            {
344                saved.mtime_nanos = mtime;
345                saved.size = meta.len();
346            }
347        }
348
349        self.ensure_entries();
350        let entries = std::mem::take(&mut self.entries);
351        let root = root.to_path_buf();
352        let entry = entry.to_path_buf();
353        let graph = graph.clone();
354        let dep_sentinels = find_dep_sentinels(&root);
355
356        CacheWriteHandle(Some(thread::spawn(move || {
357            write_cache_to_disk(
358                root,
359                entry,
360                graph,
361                entries,
362                file_mtimes,
363                unresolved_specifiers,
364                unresolvable_dynamic,
365                unresolvable_dynamic_files,
366                dep_sentinels,
367            );
368        })))
369    }
370
371    /// Save the full graph + parse cache to disk. File mtime collection,
372    /// serialization, and disk write all happen on a background thread.
373    pub fn save(
374        &mut self,
375        root: &Path,
376        entry: &Path,
377        graph: &ModuleGraph,
378        unresolved_specifiers: Vec<String>,
379        unresolvable_dynamic: usize,
380        unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
381    ) -> CacheWriteHandle {
382        self.ensure_entries();
383        let entries = std::mem::take(&mut self.entries);
384        let root = root.to_path_buf();
385        let entry = entry.to_path_buf();
386        let graph = graph.clone();
387
388        let dep_sentinels = find_dep_sentinels(&root);
389
390        CacheWriteHandle(Some(thread::spawn(move || {
391            let file_mtimes: HashMap<PathBuf, CachedMtime> = graph
392                .modules
393                .par_iter()
394                .filter_map(|m| {
395                    let meta = fs::metadata(&m.path).ok()?;
396                    let mtime = mtime_of(&meta)?;
397                    Some((
398                        m.path.clone(),
399                        CachedMtime {
400                            mtime_nanos: mtime,
401                            size: meta.len(),
402                        },
403                    ))
404                })
405                .collect();
406
407            write_cache_to_disk(
408                root,
409                entry,
410                graph,
411                entries,
412                file_mtimes,
413                unresolved_specifiers,
414                unresolvable_dynamic,
415                unresolvable_dynamic_files,
416                dep_sentinels,
417            );
418        })))
419    }
420
421    pub fn lookup(&mut self, path: &Path) -> Option<(ParseResult, Vec<Option<PathBuf>>)> {
422        self.ensure_entries();
423        let entry = self.entries.get(path)?;
424        let meta = fs::metadata(path).ok()?;
425        let current_mtime = mtime_of(&meta)?;
426        if current_mtime == entry.mtime_nanos && meta.len() == entry.size {
427            Some((entry.result.clone(), entry.resolved_paths.clone()))
428        } else {
429            None
430        }
431    }
432
433    pub fn insert(
434        &mut self,
435        path: PathBuf,
436        size: u64,
437        mtime_nanos: u128,
438        result: ParseResult,
439        resolved_paths: Vec<Option<PathBuf>>,
440    ) {
441        self.ensure_entries();
442        self.entries.insert(
443            path,
444            CachedParse {
445                mtime_nanos,
446                size,
447                result,
448                resolved_paths,
449            },
450        );
451    }
452}
453
454/// Serialize and write the cache to disk. Runs on a background thread.
455#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
456fn write_cache_to_disk(
457    root: PathBuf,
458    entry: PathBuf,
459    graph: ModuleGraph,
460    entries: HashMap<PathBuf, CachedParse>,
461    file_mtimes: HashMap<PathBuf, CachedMtime>,
462    unresolved_specifiers: Vec<String>,
463    unresolvable_dynamic: usize,
464    unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
465    dep_sentinels: Vec<(PathBuf, u128)>,
466) {
467    let graph_cache = CachedGraph {
468        entry,
469        graph,
470        file_mtimes,
471        unresolved_specifiers,
472        unresolvable_dynamic,
473        unresolvable_dynamic_files,
474        dep_sentinels,
475    };
476
477    let graph_data = match bitcode::serialize(&graph_cache) {
478        Ok(d) => d,
479        Err(e) => {
480            eprintln!("warning: failed to serialize graph cache: {e}");
481            return;
482        }
483    };
484    let parse_data = match bitcode::serialize(&entries) {
485        Ok(d) => d,
486        Err(e) => {
487            eprintln!("warning: failed to serialize parse cache: {e}");
488            return;
489        }
490    };
491
492    let mut out = Vec::with_capacity(HEADER_SIZE + graph_data.len() + parse_data.len());
493    out.extend_from_slice(&CACHE_MAGIC.to_le_bytes());
494    out.extend_from_slice(&CACHE_VERSION.to_le_bytes());
495    out.extend_from_slice(&(graph_data.len() as u64).to_le_bytes());
496    out.extend_from_slice(&graph_data);
497    out.extend_from_slice(&parse_data);
498
499    if let Err(e) = fs::write(cache_path(&root), &out) {
500        eprintln!("warning: failed to write cache: {e}");
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::graph::EdgeKind;
508    use crate::lang::RawImport;
509
510    /// Helper: insert into parse cache by stat-ing the file for mtime/size.
511    fn insert_with_stat(
512        cache: &mut ParseCache,
513        path: PathBuf,
514        result: ParseResult,
515        resolved: Vec<Option<PathBuf>>,
516    ) {
517        let meta = fs::metadata(&path).unwrap();
518        let mtime = mtime_of(&meta).unwrap();
519        cache.insert(path, meta.len(), mtime, result, resolved);
520    }
521
522    #[test]
523    fn parse_cache_hit_when_unchanged() {
524        let tmp = tempfile::tempdir().unwrap();
525        let root = tmp.path().canonicalize().unwrap();
526        let file = root.join("test.py");
527        fs::write(&file, "import os").unwrap();
528
529        let mut cache = ParseCache::new();
530        let result = ParseResult {
531            imports: vec![RawImport {
532                specifier: "os".into(),
533                kind: EdgeKind::Static,
534            }],
535            unresolvable_dynamic: 0,
536        };
537        let resolved = vec![None];
538        insert_with_stat(&mut cache, file.clone(), result, resolved);
539
540        let cached = cache.lookup(&file);
541        assert!(cached.is_some());
542        let (parse_result, resolved_paths) = cached.unwrap();
543        assert_eq!(parse_result.imports.len(), 1);
544        assert_eq!(resolved_paths.len(), 1);
545        assert!(resolved_paths[0].is_none());
546    }
547
548    #[test]
549    fn parse_cache_miss_when_modified() {
550        let tmp = tempfile::tempdir().unwrap();
551        let root = tmp.path().canonicalize().unwrap();
552        let file = root.join("test.py");
553        fs::write(&file, "import os").unwrap();
554
555        let mut cache = ParseCache::new();
556        let result = ParseResult {
557            imports: vec![],
558            unresolvable_dynamic: 0,
559        };
560        insert_with_stat(&mut cache, file.clone(), result, vec![]);
561
562        fs::write(&file, "import os\nimport sys").unwrap();
563
564        assert!(cache.lookup(&file).is_none());
565    }
566
567    #[test]
568    fn parse_cache_save_and_load_roundtrip() {
569        let tmp = tempfile::tempdir().unwrap();
570        let root = tmp.path().canonicalize().unwrap();
571        let file = root.join("test.py");
572        let target = root.join("os_impl.py");
573        fs::write(&file, "import os").unwrap();
574        fs::write(&target, "").unwrap();
575
576        let mut cache = ParseCache::new();
577        let result = ParseResult {
578            imports: vec![RawImport {
579                specifier: "os".into(),
580                kind: EdgeKind::Static,
581            }],
582            unresolvable_dynamic: 1,
583        };
584        let resolved = vec![Some(target.clone())];
585        insert_with_stat(&mut cache, file.clone(), result, resolved);
586
587        let graph = ModuleGraph::new();
588        drop(cache.save(&root, &file, &graph, vec![], 0, vec![]));
589
590        let mut loaded = ParseCache::load(&root);
591        let cached = loaded.lookup(&file);
592        assert!(cached.is_some());
593        let (parse_result, resolved_paths) = cached.unwrap();
594        assert_eq!(parse_result.imports.len(), 1);
595        assert_eq!(parse_result.imports[0].specifier, "os");
596        assert_eq!(parse_result.unresolvable_dynamic, 1);
597        assert_eq!(resolved_paths.len(), 1);
598        assert_eq!(resolved_paths[0], Some(target));
599    }
600
601    #[test]
602    fn graph_cache_valid_when_unchanged() {
603        let tmp = tempfile::tempdir().unwrap();
604        let root = tmp.path().canonicalize().unwrap();
605        let file = root.join("entry.py");
606        fs::write(&file, "x = 1").unwrap();
607
608        let mut graph = ModuleGraph::new();
609        let size = fs::metadata(&file).unwrap().len();
610        graph.add_module(file.clone(), size, None);
611
612        let mut cache = ParseCache::new();
613        drop(cache.save(&root, &file, &graph, vec!["os".into()], 2, vec![]));
614
615        let mut loaded = ParseCache::load(&root);
616        let resolve_fn = |_: &str| false;
617        let result = loaded.try_load_graph(&file, &resolve_fn);
618        assert!(matches!(result, GraphCacheResult::Hit { .. }));
619        if let GraphCacheResult::Hit {
620            graph: g,
621            unresolvable_dynamic: unresolvable,
622            ..
623        } = result
624        {
625            assert_eq!(g.module_count(), 1);
626            assert_eq!(unresolvable, 2);
627        }
628    }
629
630    #[test]
631    fn graph_cache_preserves_per_file_unresolvable_dynamic() {
632        let tmp = tempfile::tempdir().unwrap();
633        let root = tmp.path().canonicalize().unwrap();
634        let file_a = root.join("a.py");
635        let file_b = root.join("b.py");
636        fs::write(&file_a, "import x").unwrap();
637        fs::write(&file_b, "import y").unwrap();
638
639        let mut graph = ModuleGraph::new();
640        let size_a = fs::metadata(&file_a).unwrap().len();
641        let size_b = fs::metadata(&file_b).unwrap().len();
642        graph.add_module(file_a.clone(), size_a, None);
643        graph.add_module(file_b.clone(), size_b, None);
644
645        let dynamic_files = vec![(file_a.clone(), 3), (file_b.clone(), 2)];
646        let mut cache = ParseCache::new();
647        drop(cache.save(&root, &file_a, &graph, vec![], 5, dynamic_files));
648
649        let mut loaded = ParseCache::load(&root);
650        let resolve_fn = |_: &str| false;
651        let result = loaded.try_load_graph(&file_a, &resolve_fn);
652        if let GraphCacheResult::Hit {
653            unresolvable_dynamic,
654            unresolvable_dynamic_files,
655            ..
656        } = result
657        {
658            assert_eq!(unresolvable_dynamic, 5);
659            assert_eq!(unresolvable_dynamic_files.len(), 2);
660            assert!(unresolvable_dynamic_files.contains(&(file_a, 3)));
661            assert!(unresolvable_dynamic_files.contains(&(file_b, 2)));
662        } else {
663            panic!("expected Hit, got {result:?}");
664        }
665    }
666
667    #[test]
668    fn graph_cache_stale_when_file_modified() {
669        let tmp = tempfile::tempdir().unwrap();
670        let root = tmp.path().canonicalize().unwrap();
671        let file = root.join("entry.py");
672        fs::write(&file, "x = 1").unwrap();
673
674        let mut graph = ModuleGraph::new();
675        let size = fs::metadata(&file).unwrap().len();
676        graph.add_module(file.clone(), size, None);
677
678        let mut cache = ParseCache::new();
679        drop(cache.save(&root, &file, &graph, vec![], 0, vec![]));
680
681        fs::write(&file, "x = 2; y = 3").unwrap();
682
683        let mut loaded = ParseCache::load(&root);
684        let resolve_fn = |_: &str| false;
685        let result = loaded.try_load_graph(&file, &resolve_fn);
686        assert!(matches!(result, GraphCacheResult::Stale { .. }));
687        if let GraphCacheResult::Stale { changed_files, .. } = result {
688            assert_eq!(changed_files.len(), 1);
689            assert_eq!(changed_files[0], file);
690        }
691    }
692
693    #[test]
694    fn graph_cache_invalidates_when_unresolved_import_resolves() {
695        let tmp = tempfile::tempdir().unwrap();
696        let root = tmp.path().canonicalize().unwrap();
697        let file = root.join("entry.py");
698        fs::write(&file, "import foo").unwrap();
699
700        let mut graph = ModuleGraph::new();
701        let size = fs::metadata(&file).unwrap().len();
702        graph.add_module(file.clone(), size, None);
703
704        let mut cache = ParseCache::new();
705        drop(cache.save(&root, &file, &graph, vec!["foo".into()], 0, vec![]));
706
707        let mut loaded = ParseCache::load(&root);
708        let resolve_fn = |spec: &str| spec == "foo";
709        assert!(matches!(
710            loaded.try_load_graph(&file, &resolve_fn),
711            GraphCacheResult::Miss
712        ));
713    }
714
715    #[test]
716    fn graph_cache_invalidates_for_different_entry() {
717        let tmp = tempfile::tempdir().unwrap();
718        let root = tmp.path().canonicalize().unwrap();
719        let file_a = root.join("a.py");
720        let file_b = root.join("b.py");
721        fs::write(&file_a, "x = 1").unwrap();
722        fs::write(&file_b, "y = 2").unwrap();
723
724        let mut graph = ModuleGraph::new();
725        let size = fs::metadata(&file_a).unwrap().len();
726        graph.add_module(file_a.clone(), size, None);
727
728        let mut cache = ParseCache::new();
729        drop(cache.save(&root, &file_a, &graph, vec![], 0, vec![]));
730
731        let mut loaded = ParseCache::load(&root);
732        let resolve_fn = |_: &str| false;
733        assert!(matches!(
734            loaded.try_load_graph(&file_b, &resolve_fn),
735            GraphCacheResult::Miss
736        ));
737    }
738
739    #[test]
740    fn incremental_save_updates_changed_mtimes() {
741        let tmp = tempfile::tempdir().unwrap();
742        let root = tmp.path().canonicalize().unwrap();
743        let file = root.join("entry.py");
744        fs::write(&file, "x = 1").unwrap();
745
746        let mut graph = ModuleGraph::new();
747        let size = fs::metadata(&file).unwrap().len();
748        graph.add_module(file.clone(), size, None);
749
750        let mut cache = ParseCache::new();
751        drop(cache.save(&root, &file, &graph, vec![], 0, vec![]));
752
753        // Modify the file — bump mtime by 2s to guarantee a different
754        // timestamp on filesystems with coarse granularity (e.g. ext4 on CI).
755        std::thread::sleep(std::time::Duration::from_millis(50));
756        fs::write(&file, "x = 2").unwrap();
757
758        let mut loaded = ParseCache::load(&root);
759        let resolve_fn = |_: &str| false;
760        let result = loaded.try_load_graph(&file, &resolve_fn);
761        assert!(matches!(result, GraphCacheResult::Stale { .. }));
762
763        if let GraphCacheResult::Stale {
764            graph,
765            changed_files,
766            ..
767        } = result
768        {
769            // Incremental save with updated mtimes
770            drop(loaded.save_incremental(&root, &file, &graph, &changed_files, 0, vec![]));
771
772            // Reload — should now be a Hit
773            let mut reloaded = ParseCache::load(&root);
774            let result = reloaded.try_load_graph(&file, &resolve_fn);
775            assert!(
776                matches!(result, GraphCacheResult::Hit { .. }),
777                "expected Hit after incremental save"
778            );
779        }
780    }
781
782    #[test]
783    fn incremental_save_preserves_per_file_unresolvable_dynamic() {
784        let tmp = tempfile::tempdir().unwrap();
785        let root = tmp.path().canonicalize().unwrap();
786        let file_a = root.join("a.py");
787        let file_b = root.join("b.py");
788        fs::write(&file_a, "x = 1").unwrap();
789        fs::write(&file_b, "y = 2").unwrap();
790
791        let mut graph = ModuleGraph::new();
792        let size_a = fs::metadata(&file_a).unwrap().len();
793        let size_b = fs::metadata(&file_b).unwrap().len();
794        graph.add_module(file_a.clone(), size_a, None);
795        graph.add_module(file_b.clone(), size_b, None);
796
797        let dynamic_files = vec![(file_a.clone(), 3), (file_b.clone(), 2)];
798        let mut cache = ParseCache::new();
799        drop(cache.save(&root, &file_a, &graph, vec![], 5, dynamic_files));
800
801        // Modify one file to trigger Stale
802        std::thread::sleep(std::time::Duration::from_millis(50));
803        fs::write(&file_b, "y = 2; z = 3").unwrap();
804
805        let mut loaded = ParseCache::load(&root);
806        let resolve_fn = |_: &str| false;
807        let result = loaded.try_load_graph(&file_a, &resolve_fn);
808
809        if let GraphCacheResult::Stale {
810            graph,
811            unresolvable_dynamic_files,
812            changed_files,
813            ..
814        } = result
815        {
816            // Per-file data survives into Stale
817            assert_eq!(unresolvable_dynamic_files.len(), 2);
818
819            // Incremental save with same per-file data
820            drop(loaded.save_incremental(
821                &root,
822                &file_a,
823                &graph,
824                &changed_files,
825                5,
826                unresolvable_dynamic_files,
827            ));
828
829            // Reload — Hit should have per-file data intact
830            let mut reloaded = ParseCache::load(&root);
831            let result = reloaded.try_load_graph(&file_a, &resolve_fn);
832            if let GraphCacheResult::Hit {
833                unresolvable_dynamic,
834                unresolvable_dynamic_files,
835                ..
836            } = result
837            {
838                assert_eq!(unresolvable_dynamic, 5);
839                assert_eq!(unresolvable_dynamic_files.len(), 2);
840                assert!(unresolvable_dynamic_files.contains(&(file_a, 3)));
841                assert!(unresolvable_dynamic_files.contains(&(file_b, 2)));
842            } else {
843                panic!("expected Hit after incremental save, got {result:?}");
844            }
845        } else {
846            panic!("expected Stale, got {result:?}");
847        }
848    }
849
850    #[test]
851    fn lockfile_sentinel_walks_up_to_workspace_root() {
852        let tmp = tempfile::tempdir().unwrap();
853        let workspace = tmp.path().canonicalize().unwrap();
854        let pkg = workspace.join("packages").join("app");
855        fs::create_dir_all(&pkg).unwrap();
856
857        // Lockfile at workspace root, not package root
858        fs::write(workspace.join("pnpm-lock.yaml"), "lockfileVersion: 9").unwrap();
859        let file = pkg.join("entry.ts");
860        fs::write(&file, "x = 1").unwrap();
861
862        let mut graph = ModuleGraph::new();
863        let size = fs::metadata(&file).unwrap().len();
864        graph.add_module(file.clone(), size, None);
865
866        // Save with package root — should find pnpm-lock.yaml via walk-up
867        let mut cache = ParseCache::new();
868        drop(cache.save(&pkg, &file, &graph, vec![], 0, vec![]));
869
870        // First load: sentinels should be present → no resave needed
871        let mut loaded = ParseCache::load(&pkg);
872        let resolve_fn = |_: &str| false;
873        let result = loaded.try_load_graph(&file, &resolve_fn);
874        match result {
875            GraphCacheResult::Hit { needs_resave, .. } => {
876                assert!(!needs_resave, "sentinels should match — no resave needed");
877            }
878            other => panic!("expected Hit, got {other:?}"),
879        }
880    }
881}