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
68pub(crate) const 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().expect("4-byte slice fits u32"));
194        let version = u32::from_le_bytes(data[4..8].try_into().expect("4-byte slice fits u32"));
195        if magic != CACHE_MAGIC || version != CACHE_VERSION {
196            return Self::new();
197        }
198        let graph_len =
199            u64::from_le_bytes(data[8..16].try_into().expect("8-byte slice fits u64")) as usize;
200        let graph_end = HEADER_SIZE + graph_len;
201        if data.len() < graph_end {
202            return Self::new();
203        }
204
205        let cached_graph: Option<CachedGraph> =
206            bitcode::deserialize(&data[HEADER_SIZE..graph_end]).ok();
207
208        let deferred = if data.len() > graph_end {
209            Some(data[graph_end..].to_vec())
210        } else {
211            None
212        };
213
214        Self {
215            entries: HashMap::new(),
216            deferred_parse_data: deferred,
217            cached_graph,
218            stale_file_mtimes: None,
219            stale_unresolved: None,
220        }
221    }
222
223    fn ensure_entries(&mut self) {
224        if let Some(bytes) = self.deferred_parse_data.take() {
225            self.entries = bitcode::deserialize(&bytes).unwrap_or_default();
226        }
227    }
228
229    /// Try to load the cached graph (tier 1).
230    ///
231    /// Returns `Hit` if all files are unchanged, `Stale` if some files changed
232    /// (incremental update possible), or `Miss` for entry mismatch/deleted files/
233    /// newly-resolved imports.
234    pub fn try_load_graph(
235        &mut self,
236        entry: &Path,
237        resolve_fn: &(dyn Fn(&str) -> bool + Sync),
238    ) -> GraphCacheResult {
239        let cached = match self.cached_graph.as_ref() {
240            Some(c) if c.entry == entry => c,
241            _ => return GraphCacheResult::Miss,
242        };
243
244        // Check all file mtimes in parallel — collect changed files.
245        // If any file is missing (deleted), return Miss.
246        let any_missing = AtomicBool::new(false);
247        let changed_files: Vec<PathBuf> = cached
248            .file_mtimes
249            .par_iter()
250            .filter_map(|(path, saved)| {
251                if let Ok(meta) = fs::metadata(path) {
252                    let mtime = mtime_of(&meta)?;
253                    if mtime != saved.mtime_nanos || meta.len() != saved.size {
254                        Some(path.clone())
255                    } else {
256                        None
257                    }
258                } else {
259                    any_missing.store(true, Ordering::Relaxed);
260                    None
261                }
262            })
263            .collect();
264
265        if any_missing.load(Ordering::Relaxed) {
266            return GraphCacheResult::Miss;
267        }
268
269        // Check if any previously-unresolved specifier now resolves.
270        // Optimization: if we have lockfile sentinels and none changed, skip the
271        // expensive re-resolution check. A new `npm install` / `pip install` would
272        // modify the lockfile, triggering the full check.
273        let sentinels_unchanged = !cached.dep_sentinels.is_empty()
274            && cached.dep_sentinels.iter().all(|(path, saved_mtime)| {
275                fs::metadata(path)
276                    .ok()
277                    .and_then(|m| mtime_of(&m))
278                    .is_some_and(|t| t == *saved_mtime)
279            });
280
281        if !sentinels_unchanged {
282            let any_resolves = cached
283                .unresolved_specifiers
284                .par_iter()
285                .any(|spec| resolve_fn(spec));
286            if any_resolves {
287                return GraphCacheResult::Miss;
288            }
289        }
290
291        if changed_files.is_empty() {
292            let cached = self
293                .cached_graph
294                .take()
295                .expect("cached_graph populated by load");
296            return GraphCacheResult::Hit {
297                graph: cached.graph,
298                unresolvable_dynamic: cached.unresolvable_dynamic,
299                unresolvable_dynamic_files: cached.unresolvable_dynamic_files,
300                unresolved_specifiers: cached.unresolved_specifiers,
301                needs_resave: !sentinels_unchanged,
302            };
303        }
304
305        // Files changed — extract graph and preserve mtimes for incremental save
306        let cached = self
307            .cached_graph
308            .take()
309            .expect("cached_graph populated by load");
310        self.stale_file_mtimes = Some(cached.file_mtimes);
311        self.stale_unresolved = Some(cached.unresolved_specifiers);
312        GraphCacheResult::Stale {
313            graph: cached.graph,
314            unresolvable_dynamic: cached.unresolvable_dynamic,
315            unresolvable_dynamic_files: cached.unresolvable_dynamic_files,
316            changed_files,
317        }
318    }
319
320    /// Get the cached parse result for a file WITHOUT verifying its mtime.
321    /// Used by incremental update to compare old imports against new parse results.
322    pub fn lookup_unchecked(&mut self, path: &Path) -> Option<&ParseResult> {
323        self.ensure_entries();
324        self.entries.get(path).map(|e| &e.result)
325    }
326
327    /// Save after incremental update. Uses the preserved `file_mtimes` from the
328    /// Stale result, updating only the changed files' mtimes instead of
329    /// re-statting every file. Serialization and disk write happen on a
330    /// background thread.
331    pub fn save_incremental(
332        &mut self,
333        root: &Path,
334        entry: &Path,
335        graph: &ModuleGraph,
336        changed_files: &[PathBuf],
337        unresolvable_dynamic: usize,
338        unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
339    ) -> CacheWriteHandle {
340        let Some(mut file_mtimes) = self.stale_file_mtimes.take() else {
341            return CacheWriteHandle::none();
342        };
343        let unresolved_specifiers = self.stale_unresolved.take().unwrap_or_default();
344
345        // Update only changed files' mtimes (cheap, typically 1-2 files)
346        for path in changed_files {
347            if let Ok(meta) = fs::metadata(path)
348                && let Some(mtime) = mtime_of(&meta)
349                && let Some(saved) = file_mtimes.get_mut(path)
350            {
351                saved.mtime_nanos = mtime;
352                saved.size = meta.len();
353            }
354        }
355
356        self.ensure_entries();
357        let entries = std::mem::take(&mut self.entries);
358        let root = root.to_path_buf();
359        let entry = entry.to_path_buf();
360        let graph = graph.clone();
361        let dep_sentinels = find_dep_sentinels(&root);
362
363        CacheWriteHandle(Some(thread::spawn(move || {
364            write_cache_to_disk(
365                root,
366                entry,
367                graph,
368                entries,
369                file_mtimes,
370                unresolved_specifiers,
371                unresolvable_dynamic,
372                unresolvable_dynamic_files,
373                dep_sentinels,
374            );
375        })))
376    }
377
378    /// Save the full graph + parse cache to disk. File mtime collection,
379    /// serialization, and disk write all happen on a background thread.
380    pub fn save(
381        &mut self,
382        root: &Path,
383        entry: &Path,
384        graph: &ModuleGraph,
385        unresolved_specifiers: Vec<String>,
386        unresolvable_dynamic: usize,
387        unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
388    ) -> CacheWriteHandle {
389        self.ensure_entries();
390        let entries = std::mem::take(&mut self.entries);
391        let root = root.to_path_buf();
392        let entry = entry.to_path_buf();
393        let graph = graph.clone();
394
395        let dep_sentinels = find_dep_sentinels(&root);
396
397        CacheWriteHandle(Some(thread::spawn(move || {
398            let file_mtimes: HashMap<PathBuf, CachedMtime> = graph
399                .modules
400                .par_iter()
401                .filter_map(|m| {
402                    let meta = fs::metadata(&m.path).ok()?;
403                    let mtime = mtime_of(&meta)?;
404                    Some((
405                        m.path.clone(),
406                        CachedMtime {
407                            mtime_nanos: mtime,
408                            size: meta.len(),
409                        },
410                    ))
411                })
412                .collect();
413
414            write_cache_to_disk(
415                root,
416                entry,
417                graph,
418                entries,
419                file_mtimes,
420                unresolved_specifiers,
421                unresolvable_dynamic,
422                unresolvable_dynamic_files,
423                dep_sentinels,
424            );
425        })))
426    }
427
428    pub fn lookup(&mut self, path: &Path) -> Option<(ParseResult, Vec<Option<PathBuf>>)> {
429        self.ensure_entries();
430        let entry = self.entries.get(path)?;
431        let meta = fs::metadata(path).ok()?;
432        let current_mtime = mtime_of(&meta)?;
433        if current_mtime == entry.mtime_nanos && meta.len() == entry.size {
434            Some((entry.result.clone(), entry.resolved_paths.clone()))
435        } else {
436            None
437        }
438    }
439
440    pub fn insert(
441        &mut self,
442        path: PathBuf,
443        size: u64,
444        mtime_nanos: u128,
445        result: ParseResult,
446        resolved_paths: Vec<Option<PathBuf>>,
447    ) {
448        self.ensure_entries();
449        self.entries.insert(
450            path,
451            CachedParse {
452                mtime_nanos,
453                size,
454                result,
455                resolved_paths,
456            },
457        );
458    }
459}
460
461/// Serialize and write the cache to disk. Runs on a background thread.
462#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
463fn write_cache_to_disk(
464    root: PathBuf,
465    entry: PathBuf,
466    graph: ModuleGraph,
467    entries: HashMap<PathBuf, CachedParse>,
468    file_mtimes: HashMap<PathBuf, CachedMtime>,
469    unresolved_specifiers: Vec<String>,
470    unresolvable_dynamic: usize,
471    unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
472    dep_sentinels: Vec<(PathBuf, u128)>,
473) {
474    let graph_cache = CachedGraph {
475        entry,
476        graph,
477        file_mtimes,
478        unresolved_specifiers,
479        unresolvable_dynamic,
480        unresolvable_dynamic_files,
481        dep_sentinels,
482    };
483
484    let graph_data = match bitcode::serialize(&graph_cache) {
485        Ok(d) => d,
486        Err(e) => {
487            eprintln!("warning: failed to serialize graph cache: {e}");
488            return;
489        }
490    };
491    let parse_data = match bitcode::serialize(&entries) {
492        Ok(d) => d,
493        Err(e) => {
494            eprintln!("warning: failed to serialize parse cache: {e}");
495            return;
496        }
497    };
498
499    let mut out = Vec::with_capacity(HEADER_SIZE + graph_data.len() + parse_data.len());
500    out.extend_from_slice(&CACHE_MAGIC.to_le_bytes());
501    out.extend_from_slice(&CACHE_VERSION.to_le_bytes());
502    out.extend_from_slice(&(graph_data.len() as u64).to_le_bytes());
503    out.extend_from_slice(&graph_data);
504    out.extend_from_slice(&parse_data);
505
506    if let Err(e) = fs::write(cache_path(&root), &out) {
507        eprintln!("warning: failed to write cache: {e}");
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::graph::EdgeKind;
515    use crate::lang::RawImport;
516
517    /// Helper: insert into parse cache by stat-ing the file for mtime/size.
518    fn insert_with_stat(
519        cache: &mut ParseCache,
520        path: PathBuf,
521        result: ParseResult,
522        resolved: Vec<Option<PathBuf>>,
523    ) {
524        let meta = fs::metadata(&path).unwrap();
525        let mtime = mtime_of(&meta).unwrap();
526        cache.insert(path, meta.len(), mtime, result, resolved);
527    }
528
529    #[test]
530    fn parse_cache_hit_when_unchanged() {
531        let tmp = tempfile::tempdir().unwrap();
532        let root = tmp.path().canonicalize().unwrap();
533        let file = root.join("test.py");
534        fs::write(&file, "import os").unwrap();
535
536        let mut cache = ParseCache::new();
537        let result = ParseResult {
538            imports: vec![RawImport {
539                specifier: "os".into(),
540                kind: EdgeKind::Static,
541            }],
542            unresolvable_dynamic: 0,
543        };
544        let resolved = vec![None];
545        insert_with_stat(&mut cache, file.clone(), result, resolved);
546
547        let cached = cache.lookup(&file);
548        assert!(cached.is_some());
549        let (parse_result, resolved_paths) = cached.unwrap();
550        assert_eq!(parse_result.imports.len(), 1);
551        assert_eq!(resolved_paths.len(), 1);
552        assert!(resolved_paths[0].is_none());
553    }
554
555    #[test]
556    fn parse_cache_miss_when_modified() {
557        let tmp = tempfile::tempdir().unwrap();
558        let root = tmp.path().canonicalize().unwrap();
559        let file = root.join("test.py");
560        fs::write(&file, "import os").unwrap();
561
562        let mut cache = ParseCache::new();
563        let result = ParseResult {
564            imports: vec![],
565            unresolvable_dynamic: 0,
566        };
567        insert_with_stat(&mut cache, file.clone(), result, vec![]);
568
569        fs::write(&file, "import os\nimport sys").unwrap();
570
571        assert!(cache.lookup(&file).is_none());
572    }
573
574    #[test]
575    fn parse_cache_save_and_load_roundtrip() {
576        let tmp = tempfile::tempdir().unwrap();
577        let root = tmp.path().canonicalize().unwrap();
578        let file = root.join("test.py");
579        let target = root.join("os_impl.py");
580        fs::write(&file, "import os").unwrap();
581        fs::write(&target, "").unwrap();
582
583        let mut cache = ParseCache::new();
584        let result = ParseResult {
585            imports: vec![RawImport {
586                specifier: "os".into(),
587                kind: EdgeKind::Static,
588            }],
589            unresolvable_dynamic: 1,
590        };
591        let resolved = vec![Some(target.clone())];
592        insert_with_stat(&mut cache, file.clone(), result, resolved);
593
594        let graph = ModuleGraph::new();
595        drop(cache.save(&root, &file, &graph, vec![], 0, vec![]));
596
597        let mut loaded = ParseCache::load(&root);
598        let cached = loaded.lookup(&file);
599        assert!(cached.is_some());
600        let (parse_result, resolved_paths) = cached.unwrap();
601        assert_eq!(parse_result.imports.len(), 1);
602        assert_eq!(parse_result.imports[0].specifier, "os");
603        assert_eq!(parse_result.unresolvable_dynamic, 1);
604        assert_eq!(resolved_paths.len(), 1);
605        assert_eq!(resolved_paths[0], Some(target));
606    }
607
608    #[test]
609    fn graph_cache_valid_when_unchanged() {
610        let tmp = tempfile::tempdir().unwrap();
611        let root = tmp.path().canonicalize().unwrap();
612        let file = root.join("entry.py");
613        fs::write(&file, "x = 1").unwrap();
614
615        let mut graph = ModuleGraph::new();
616        let size = fs::metadata(&file).unwrap().len();
617        graph.add_module(file.clone(), size, None);
618
619        let mut cache = ParseCache::new();
620        drop(cache.save(&root, &file, &graph, vec!["os".into()], 2, vec![]));
621
622        let mut loaded = ParseCache::load(&root);
623        let resolve_fn = |_: &str| false;
624        let result = loaded.try_load_graph(&file, &resolve_fn);
625        assert!(matches!(result, GraphCacheResult::Hit { .. }));
626        if let GraphCacheResult::Hit {
627            graph: g,
628            unresolvable_dynamic: unresolvable,
629            ..
630        } = result
631        {
632            assert_eq!(g.module_count(), 1);
633            assert_eq!(unresolvable, 2);
634        }
635    }
636
637    #[test]
638    fn graph_cache_preserves_per_file_unresolvable_dynamic() {
639        let tmp = tempfile::tempdir().unwrap();
640        let root = tmp.path().canonicalize().unwrap();
641        let file_a = root.join("a.py");
642        let file_b = root.join("b.py");
643        fs::write(&file_a, "import x").unwrap();
644        fs::write(&file_b, "import y").unwrap();
645
646        let mut graph = ModuleGraph::new();
647        let size_a = fs::metadata(&file_a).unwrap().len();
648        let size_b = fs::metadata(&file_b).unwrap().len();
649        graph.add_module(file_a.clone(), size_a, None);
650        graph.add_module(file_b.clone(), size_b, None);
651
652        let dynamic_files = vec![(file_a.clone(), 3), (file_b.clone(), 2)];
653        let mut cache = ParseCache::new();
654        drop(cache.save(&root, &file_a, &graph, vec![], 5, dynamic_files));
655
656        let mut loaded = ParseCache::load(&root);
657        let resolve_fn = |_: &str| false;
658        let result = loaded.try_load_graph(&file_a, &resolve_fn);
659        if let GraphCacheResult::Hit {
660            unresolvable_dynamic,
661            unresolvable_dynamic_files,
662            ..
663        } = result
664        {
665            assert_eq!(unresolvable_dynamic, 5);
666            assert_eq!(unresolvable_dynamic_files.len(), 2);
667            assert!(unresolvable_dynamic_files.contains(&(file_a, 3)));
668            assert!(unresolvable_dynamic_files.contains(&(file_b, 2)));
669        } else {
670            panic!("expected Hit, got {result:?}");
671        }
672    }
673
674    #[test]
675    fn graph_cache_stale_when_file_modified() {
676        let tmp = tempfile::tempdir().unwrap();
677        let root = tmp.path().canonicalize().unwrap();
678        let file = root.join("entry.py");
679        fs::write(&file, "x = 1").unwrap();
680
681        let mut graph = ModuleGraph::new();
682        let size = fs::metadata(&file).unwrap().len();
683        graph.add_module(file.clone(), size, None);
684
685        let mut cache = ParseCache::new();
686        drop(cache.save(&root, &file, &graph, vec![], 0, vec![]));
687
688        fs::write(&file, "x = 2; y = 3").unwrap();
689
690        let mut loaded = ParseCache::load(&root);
691        let resolve_fn = |_: &str| false;
692        let result = loaded.try_load_graph(&file, &resolve_fn);
693        assert!(matches!(result, GraphCacheResult::Stale { .. }));
694        if let GraphCacheResult::Stale { changed_files, .. } = result {
695            assert_eq!(changed_files.len(), 1);
696            assert_eq!(changed_files[0], file);
697        }
698    }
699
700    #[test]
701    fn graph_cache_invalidates_when_unresolved_import_resolves() {
702        let tmp = tempfile::tempdir().unwrap();
703        let root = tmp.path().canonicalize().unwrap();
704        let file = root.join("entry.py");
705        fs::write(&file, "import foo").unwrap();
706
707        let mut graph = ModuleGraph::new();
708        let size = fs::metadata(&file).unwrap().len();
709        graph.add_module(file.clone(), size, None);
710
711        let mut cache = ParseCache::new();
712        drop(cache.save(&root, &file, &graph, vec!["foo".into()], 0, vec![]));
713
714        let mut loaded = ParseCache::load(&root);
715        let resolve_fn = |spec: &str| spec == "foo";
716        assert!(matches!(
717            loaded.try_load_graph(&file, &resolve_fn),
718            GraphCacheResult::Miss
719        ));
720    }
721
722    #[test]
723    fn graph_cache_invalidates_for_different_entry() {
724        let tmp = tempfile::tempdir().unwrap();
725        let root = tmp.path().canonicalize().unwrap();
726        let file_a = root.join("a.py");
727        let file_b = root.join("b.py");
728        fs::write(&file_a, "x = 1").unwrap();
729        fs::write(&file_b, "y = 2").unwrap();
730
731        let mut graph = ModuleGraph::new();
732        let size = fs::metadata(&file_a).unwrap().len();
733        graph.add_module(file_a.clone(), size, None);
734
735        let mut cache = ParseCache::new();
736        drop(cache.save(&root, &file_a, &graph, vec![], 0, vec![]));
737
738        let mut loaded = ParseCache::load(&root);
739        let resolve_fn = |_: &str| false;
740        assert!(matches!(
741            loaded.try_load_graph(&file_b, &resolve_fn),
742            GraphCacheResult::Miss
743        ));
744    }
745
746    #[test]
747    fn incremental_save_updates_changed_mtimes() {
748        let tmp = tempfile::tempdir().unwrap();
749        let root = tmp.path().canonicalize().unwrap();
750        let file = root.join("entry.py");
751        fs::write(&file, "x = 1").unwrap();
752
753        let mut graph = ModuleGraph::new();
754        let size = fs::metadata(&file).unwrap().len();
755        graph.add_module(file.clone(), size, None);
756
757        let mut cache = ParseCache::new();
758        drop(cache.save(&root, &file, &graph, vec![], 0, vec![]));
759
760        // Modify the file — bump mtime by 2s to guarantee a different
761        // timestamp on filesystems with coarse granularity (e.g. ext4 on CI).
762        std::thread::sleep(std::time::Duration::from_millis(50));
763        fs::write(&file, "x = 2").unwrap();
764
765        let mut loaded = ParseCache::load(&root);
766        let resolve_fn = |_: &str| false;
767        let result = loaded.try_load_graph(&file, &resolve_fn);
768        assert!(matches!(result, GraphCacheResult::Stale { .. }));
769
770        if let GraphCacheResult::Stale {
771            graph,
772            changed_files,
773            ..
774        } = result
775        {
776            // Incremental save with updated mtimes
777            drop(loaded.save_incremental(&root, &file, &graph, &changed_files, 0, vec![]));
778
779            // Reload — should now be a Hit
780            let mut reloaded = ParseCache::load(&root);
781            let result = reloaded.try_load_graph(&file, &resolve_fn);
782            assert!(
783                matches!(result, GraphCacheResult::Hit { .. }),
784                "expected Hit after incremental save"
785            );
786        }
787    }
788
789    #[test]
790    fn incremental_save_preserves_per_file_unresolvable_dynamic() {
791        let tmp = tempfile::tempdir().unwrap();
792        let root = tmp.path().canonicalize().unwrap();
793        let file_a = root.join("a.py");
794        let file_b = root.join("b.py");
795        fs::write(&file_a, "x = 1").unwrap();
796        fs::write(&file_b, "y = 2").unwrap();
797
798        let mut graph = ModuleGraph::new();
799        let size_a = fs::metadata(&file_a).unwrap().len();
800        let size_b = fs::metadata(&file_b).unwrap().len();
801        graph.add_module(file_a.clone(), size_a, None);
802        graph.add_module(file_b.clone(), size_b, None);
803
804        let dynamic_files = vec![(file_a.clone(), 3), (file_b.clone(), 2)];
805        let mut cache = ParseCache::new();
806        drop(cache.save(&root, &file_a, &graph, vec![], 5, dynamic_files));
807
808        // Modify one file to trigger Stale
809        std::thread::sleep(std::time::Duration::from_millis(50));
810        fs::write(&file_b, "y = 2; z = 3").unwrap();
811
812        let mut loaded = ParseCache::load(&root);
813        let resolve_fn = |_: &str| false;
814        let result = loaded.try_load_graph(&file_a, &resolve_fn);
815
816        if let GraphCacheResult::Stale {
817            graph,
818            unresolvable_dynamic_files,
819            changed_files,
820            ..
821        } = result
822        {
823            // Per-file data survives into Stale
824            assert_eq!(unresolvable_dynamic_files.len(), 2);
825
826            // Incremental save with same per-file data
827            drop(loaded.save_incremental(
828                &root,
829                &file_a,
830                &graph,
831                &changed_files,
832                5,
833                unresolvable_dynamic_files,
834            ));
835
836            // Reload — Hit should have per-file data intact
837            let mut reloaded = ParseCache::load(&root);
838            let result = reloaded.try_load_graph(&file_a, &resolve_fn);
839            if let GraphCacheResult::Hit {
840                unresolvable_dynamic,
841                unresolvable_dynamic_files,
842                ..
843            } = result
844            {
845                assert_eq!(unresolvable_dynamic, 5);
846                assert_eq!(unresolvable_dynamic_files.len(), 2);
847                assert!(unresolvable_dynamic_files.contains(&(file_a, 3)));
848                assert!(unresolvable_dynamic_files.contains(&(file_b, 2)));
849            } else {
850                panic!("expected Hit after incremental save, got {result:?}");
851            }
852        } else {
853            panic!("expected Stale, got {result:?}");
854        }
855    }
856
857    #[test]
858    fn lockfile_sentinel_walks_up_to_workspace_root() {
859        let tmp = tempfile::tempdir().unwrap();
860        let workspace = tmp.path().canonicalize().unwrap();
861        let pkg = workspace.join("packages").join("app");
862        fs::create_dir_all(&pkg).unwrap();
863
864        // Lockfile at workspace root, not package root
865        fs::write(workspace.join("pnpm-lock.yaml"), "lockfileVersion: 9").unwrap();
866        let file = pkg.join("entry.ts");
867        fs::write(&file, "x = 1").unwrap();
868
869        let mut graph = ModuleGraph::new();
870        let size = fs::metadata(&file).unwrap().len();
871        graph.add_module(file.clone(), size, None);
872
873        // Save with package root — should find pnpm-lock.yaml via walk-up
874        let mut cache = ParseCache::new();
875        drop(cache.save(&pkg, &file, &graph, vec![], 0, vec![]));
876
877        // First load: sentinels should be present → no resave needed
878        let mut loaded = ParseCache::load(&pkg);
879        let resolve_fn = |_: &str| false;
880        let result = loaded.try_load_graph(&file, &resolve_fn);
881        match result {
882            GraphCacheResult::Hit { needs_resave, .. } => {
883                assert!(!needs_resave, "sentinels should match — no resave needed");
884            }
885            other => panic!("expected Hit, got {other:?}"),
886        }
887    }
888}