Skip to main content

fallow_extract/
cache.rs

1//! Incremental parse cache with bincode serialization.
2//!
3//! Stores parsed module information (exports, imports, re-exports) on disk so
4//! unchanged files can skip AST parsing on subsequent runs. Uses xxh3 content
5//! hashing to detect changes.
6
7use std::path::Path;
8
9use rustc_hash::FxHashMap;
10
11use bincode::{Decode, Encode};
12
13use oxc_span::Span;
14
15use crate::{ExportName, MemberAccess, MemberKind};
16
17/// Cache version — bump when the cache format changes.
18const CACHE_VERSION: u32 = 11;
19
20/// Maximum cache file size to deserialize (256 MB).
21const MAX_CACHE_SIZE: usize = 256 * 1024 * 1024;
22
23/// Cached module information stored on disk.
24#[derive(Debug, Encode, Decode)]
25pub struct CacheStore {
26    version: u32,
27    /// Map from file path to cached module data.
28    entries: FxHashMap<String, CachedModule>,
29}
30
31/// Cached data for a single module.
32#[derive(Debug, Clone, Encode, Decode)]
33pub struct CachedModule {
34    /// xxh3 hash of the file content.
35    pub content_hash: u64,
36    /// File modification time (seconds since epoch) for fast cache validation.
37    /// When mtime+size match the on-disk file, we skip reading file content entirely.
38    pub mtime_secs: u64,
39    /// File size in bytes for fast cache validation.
40    pub file_size: u64,
41    /// Exported symbols.
42    pub exports: Vec<CachedExport>,
43    /// Import specifiers.
44    pub imports: Vec<CachedImport>,
45    /// Re-export specifiers.
46    pub re_exports: Vec<CachedReExport>,
47    /// Dynamic import specifiers.
48    pub dynamic_imports: Vec<CachedDynamicImport>,
49    /// `require()` specifiers.
50    pub require_calls: Vec<CachedRequireCall>,
51    /// Static member accesses (e.g., `Status.Active`).
52    pub member_accesses: Vec<MemberAccess>,
53    /// Identifiers used as whole objects (Object.values, for..in, spread, etc.).
54    pub whole_object_uses: Vec<String>,
55    /// Dynamic import patterns with partial static resolution.
56    pub dynamic_import_patterns: Vec<CachedDynamicImportPattern>,
57    /// Whether this module uses CJS exports.
58    pub has_cjs_exports: bool,
59    /// Inline suppression directives.
60    pub suppressions: Vec<CachedSuppression>,
61    /// Pre-computed line-start byte offsets for O(log N) byte-to-line/col conversion.
62    pub line_offsets: Vec<u32>,
63}
64
65/// Cached suppression directive.
66#[derive(Debug, Clone, Encode, Decode)]
67pub struct CachedSuppression {
68    /// 1-based line this suppression applies to. 0 = file-wide.
69    pub line: u32,
70    /// 0 = suppress all, 1-10 = `IssueKind` discriminant.
71    pub kind: u8,
72}
73
74/// Cached export data for a single export declaration.
75#[derive(Debug, Clone, Encode, Decode)]
76pub struct CachedExport {
77    /// Export name (or "default" for default exports).
78    pub name: String,
79    /// Whether this is a default export.
80    pub is_default: bool,
81    /// Whether this is a type-only export.
82    pub is_type_only: bool,
83    /// The local binding name, if different.
84    pub local_name: Option<String>,
85    /// Byte offset of the export span start.
86    pub span_start: u32,
87    /// Byte offset of the export span end.
88    pub span_end: u32,
89    /// Members of this export (for enums and classes).
90    pub members: Vec<CachedMember>,
91}
92
93/// Import kind discriminant for `CachedImport`:
94/// 0 = Named, 1 = Default, 2 = Namespace, 3 = `SideEffect`.
95const IMPORT_KIND_NAMED: u8 = 0;
96const IMPORT_KIND_DEFAULT: u8 = 1;
97const IMPORT_KIND_NAMESPACE: u8 = 2;
98const IMPORT_KIND_SIDE_EFFECT: u8 = 3;
99
100/// Cached import data for a single import declaration.
101#[derive(Debug, Clone, Encode, Decode)]
102pub struct CachedImport {
103    /// The import specifier.
104    pub source: String,
105    /// For Named imports, the imported symbol name. Empty for other kinds.
106    pub imported_name: String,
107    /// The local binding name.
108    pub local_name: String,
109    /// Whether this is a type-only import.
110    pub is_type_only: bool,
111    /// Import kind: 0=Named, 1=Default, 2=Namespace, 3=SideEffect.
112    pub kind: u8,
113    /// Byte offset of the import span start.
114    pub span_start: u32,
115    /// Byte offset of the import span end.
116    pub span_end: u32,
117}
118
119/// Cached dynamic import data.
120#[derive(Debug, Clone, Encode, Decode)]
121pub struct CachedDynamicImport {
122    /// The import specifier.
123    pub source: String,
124    /// Byte offset of the span start.
125    pub span_start: u32,
126    /// Byte offset of the span end.
127    pub span_end: u32,
128    /// Names destructured from the import result.
129    pub destructured_names: Vec<String>,
130    /// Local variable name for namespace imports.
131    pub local_name: Option<String>,
132}
133
134/// Cached `require()` call data.
135#[derive(Debug, Clone, Encode, Decode)]
136pub struct CachedRequireCall {
137    /// The require specifier.
138    pub source: String,
139    /// Byte offset of the span start.
140    pub span_start: u32,
141    /// Byte offset of the span end.
142    pub span_end: u32,
143    /// Names destructured from the require result.
144    pub destructured_names: Vec<String>,
145    /// Local variable name for namespace requires.
146    pub local_name: Option<String>,
147}
148
149/// Cached re-export data.
150#[derive(Debug, Clone, Encode, Decode)]
151pub struct CachedReExport {
152    /// The module being re-exported from.
153    pub source: String,
154    /// Name imported from the source.
155    pub imported_name: String,
156    /// Name exported from this module.
157    pub exported_name: String,
158    /// Whether this is a type-only re-export.
159    pub is_type_only: bool,
160}
161
162/// Cached enum or class member data.
163#[derive(Debug, Clone, Encode, Decode)]
164pub struct CachedMember {
165    /// Member name.
166    pub name: String,
167    /// Member kind (enum, method, or property).
168    pub kind: MemberKind,
169    /// Byte offset of the span start.
170    pub span_start: u32,
171    /// Byte offset of the span end.
172    pub span_end: u32,
173    /// Whether this member has decorators.
174    pub has_decorator: bool,
175}
176
177/// Cached dynamic import pattern data (template literals, `import.meta.glob`).
178#[derive(Debug, Clone, Encode, Decode)]
179pub struct CachedDynamicImportPattern {
180    /// Static prefix of the import path.
181    pub prefix: String,
182    /// Static suffix, if any.
183    pub suffix: Option<String>,
184    /// Byte offset of the span start.
185    pub span_start: u32,
186    /// Byte offset of the span end.
187    pub span_end: u32,
188}
189
190impl CacheStore {
191    /// Create a new empty cache.
192    pub fn new() -> Self {
193        Self {
194            version: CACHE_VERSION,
195            entries: FxHashMap::default(),
196        }
197    }
198
199    /// Load cache from disk.
200    pub fn load(cache_dir: &Path) -> Option<Self> {
201        let cache_file = cache_dir.join("cache.bin");
202        let data = std::fs::read(&cache_file).ok()?;
203        if data.len() > MAX_CACHE_SIZE {
204            tracing::warn!(
205                size_mb = data.len() / (1024 * 1024),
206                "Cache file exceeds size limit, ignoring"
207            );
208            return None;
209        }
210        let (store, _): (Self, usize) =
211            bincode::decode_from_slice(&data, bincode::config::standard()).ok()?;
212        if store.version != CACHE_VERSION {
213            return None;
214        }
215        Some(store)
216    }
217
218    /// Save cache to disk.
219    pub fn save(&self, cache_dir: &Path) -> Result<(), String> {
220        std::fs::create_dir_all(cache_dir)
221            .map_err(|e| format!("Failed to create cache dir: {e}"))?;
222        let cache_file = cache_dir.join("cache.bin");
223        let data = bincode::encode_to_vec(self, bincode::config::standard())
224            .map_err(|e| format!("Failed to serialize cache: {e}"))?;
225        std::fs::write(&cache_file, data).map_err(|e| format!("Failed to write cache: {e}"))?;
226        Ok(())
227    }
228
229    /// Look up a cached module by path and content hash.
230    /// Returns None if not cached or hash mismatch.
231    pub fn get(&self, path: &Path, content_hash: u64) -> Option<&CachedModule> {
232        let key = path.to_string_lossy().to_string();
233        let entry = self.entries.get(&key)?;
234        if entry.content_hash == content_hash {
235            Some(entry)
236        } else {
237            None
238        }
239    }
240
241    /// Insert or update a cached module.
242    pub fn insert(&mut self, path: &Path, module: CachedModule) {
243        let key = path.to_string_lossy().to_string();
244        self.entries.insert(key, module);
245    }
246
247    /// Fast cache lookup using only file metadata (mtime + size).
248    ///
249    /// If the cached entry has matching mtime and size, the file content
250    /// almost certainly has not changed, so we can skip reading the file
251    /// entirely. This turns a cache hit from `stat() + read() + hash`
252    /// into just `stat()`.
253    pub fn get_by_metadata(
254        &self,
255        path: &Path,
256        mtime_secs: u64,
257        file_size: u64,
258    ) -> Option<&CachedModule> {
259        let key = path.to_string_lossy().to_string();
260        let entry = self.entries.get(&key)?;
261        if entry.mtime_secs == mtime_secs && entry.file_size == file_size && mtime_secs > 0 {
262            Some(entry)
263        } else {
264            None
265        }
266    }
267
268    /// Look up a cached module by path only (ignoring hash).
269    /// Used to check whether a module's content hash matches without
270    /// requiring the caller to know the hash upfront.
271    pub fn get_by_path_only(&self, path: &Path) -> Option<&CachedModule> {
272        let key = path.to_string_lossy().to_string();
273        self.entries.get(&key)
274    }
275
276    /// Remove cache entries for files that are no longer in the project.
277    /// Keeps the cache from growing unboundedly as files are deleted.
278    pub fn retain_paths(&mut self, files: &[fallow_types::discover::DiscoveredFile]) {
279        use rustc_hash::FxHashSet;
280        let current_paths: FxHashSet<String> = files
281            .iter()
282            .map(|f| f.path.to_string_lossy().to_string())
283            .collect();
284        self.entries.retain(|key, _| current_paths.contains(key));
285    }
286
287    /// Number of cached entries.
288    pub fn len(&self) -> usize {
289        self.entries.len()
290    }
291
292    /// Whether cache is empty.
293    pub fn is_empty(&self) -> bool {
294        self.entries.is_empty()
295    }
296}
297
298impl Default for CacheStore {
299    fn default() -> Self {
300        Self::new()
301    }
302}
303
304/// Reconstruct a [`ModuleInfo`](crate::ModuleInfo) from a [`CachedModule`].
305pub fn cached_to_module(
306    cached: &CachedModule,
307    file_id: fallow_types::discover::FileId,
308) -> crate::ModuleInfo {
309    use crate::*;
310
311    let exports = cached
312        .exports
313        .iter()
314        .map(|e| ExportInfo {
315            name: if e.is_default {
316                ExportName::Default
317            } else {
318                ExportName::Named(e.name.clone())
319            },
320            local_name: e.local_name.clone(),
321            is_type_only: e.is_type_only,
322            span: Span::new(e.span_start, e.span_end),
323            members: e
324                .members
325                .iter()
326                .map(|m| MemberInfo {
327                    name: m.name.clone(),
328                    kind: m.kind.clone(),
329                    span: Span::new(m.span_start, m.span_end),
330                    has_decorator: m.has_decorator,
331                })
332                .collect(),
333        })
334        .collect();
335
336    let imports = cached
337        .imports
338        .iter()
339        .map(|i| ImportInfo {
340            source: i.source.clone(),
341            imported_name: match i.kind {
342                IMPORT_KIND_DEFAULT => ImportedName::Default,
343                IMPORT_KIND_NAMESPACE => ImportedName::Namespace,
344                IMPORT_KIND_SIDE_EFFECT => ImportedName::SideEffect,
345                // IMPORT_KIND_NAMED (0) and any unknown value default to Named
346                _ => ImportedName::Named(i.imported_name.clone()),
347            },
348            local_name: i.local_name.clone(),
349            is_type_only: i.is_type_only,
350            span: Span::new(i.span_start, i.span_end),
351        })
352        .collect();
353
354    let re_exports = cached
355        .re_exports
356        .iter()
357        .map(|r| ReExportInfo {
358            source: r.source.clone(),
359            imported_name: r.imported_name.clone(),
360            exported_name: r.exported_name.clone(),
361            is_type_only: r.is_type_only,
362        })
363        .collect();
364
365    let dynamic_imports = cached
366        .dynamic_imports
367        .iter()
368        .map(|d| DynamicImportInfo {
369            source: d.source.clone(),
370            span: Span::new(d.span_start, d.span_end),
371            destructured_names: d.destructured_names.clone(),
372            local_name: d.local_name.clone(),
373        })
374        .collect();
375
376    let require_calls = cached
377        .require_calls
378        .iter()
379        .map(|r| RequireCallInfo {
380            source: r.source.clone(),
381            span: Span::new(r.span_start, r.span_end),
382            destructured_names: r.destructured_names.clone(),
383            local_name: r.local_name.clone(),
384        })
385        .collect();
386
387    let dynamic_import_patterns = cached
388        .dynamic_import_patterns
389        .iter()
390        .map(|p| crate::DynamicImportPattern {
391            prefix: p.prefix.clone(),
392            suffix: p.suffix.clone(),
393            span: Span::new(p.span_start, p.span_end),
394        })
395        .collect();
396
397    let suppressions = cached
398        .suppressions
399        .iter()
400        .map(|s| crate::suppress::Suppression {
401            line: s.line,
402            kind: if s.kind == 0 {
403                None
404            } else {
405                crate::suppress::IssueKind::from_discriminant(s.kind)
406            },
407        })
408        .collect();
409
410    ModuleInfo {
411        file_id,
412        exports,
413        imports,
414        re_exports,
415        dynamic_imports,
416        dynamic_import_patterns,
417        require_calls,
418        member_accesses: cached.member_accesses.clone(),
419        whole_object_uses: cached.whole_object_uses.clone(),
420        has_cjs_exports: cached.has_cjs_exports,
421        content_hash: cached.content_hash,
422        suppressions,
423        line_offsets: cached.line_offsets.clone(),
424    }
425}
426
427/// Convert a [`ModuleInfo`](crate::ModuleInfo) to a [`CachedModule`] for storage.
428///
429/// `mtime_secs` and `file_size` come from `std::fs::metadata()` at parse time
430/// and enable fast cache validation on subsequent runs (skip file read when
431/// mtime+size match).
432pub fn module_to_cached(
433    module: &crate::ModuleInfo,
434    mtime_secs: u64,
435    file_size: u64,
436) -> CachedModule {
437    CachedModule {
438        content_hash: module.content_hash,
439        mtime_secs,
440        file_size,
441        exports: module
442            .exports
443            .iter()
444            .map(|e| CachedExport {
445                name: match &e.name {
446                    ExportName::Named(n) => n.clone(),
447                    ExportName::Default => String::new(),
448                },
449                is_default: matches!(e.name, ExportName::Default),
450                is_type_only: e.is_type_only,
451                local_name: e.local_name.clone(),
452                span_start: e.span.start,
453                span_end: e.span.end,
454                members: e
455                    .members
456                    .iter()
457                    .map(|m| CachedMember {
458                        name: m.name.clone(),
459                        kind: m.kind.clone(),
460                        span_start: m.span.start,
461                        span_end: m.span.end,
462                        has_decorator: m.has_decorator,
463                    })
464                    .collect(),
465            })
466            .collect(),
467        imports: module
468            .imports
469            .iter()
470            .map(|i| {
471                let (kind, imported_name) = match &i.imported_name {
472                    crate::ImportedName::Named(n) => (IMPORT_KIND_NAMED, n.clone()),
473                    crate::ImportedName::Default => (IMPORT_KIND_DEFAULT, String::new()),
474                    crate::ImportedName::Namespace => (IMPORT_KIND_NAMESPACE, String::new()),
475                    crate::ImportedName::SideEffect => (IMPORT_KIND_SIDE_EFFECT, String::new()),
476                };
477                CachedImport {
478                    source: i.source.clone(),
479                    imported_name,
480                    local_name: i.local_name.clone(),
481                    is_type_only: i.is_type_only,
482                    kind,
483                    span_start: i.span.start,
484                    span_end: i.span.end,
485                }
486            })
487            .collect(),
488        re_exports: module
489            .re_exports
490            .iter()
491            .map(|r| CachedReExport {
492                source: r.source.clone(),
493                imported_name: r.imported_name.clone(),
494                exported_name: r.exported_name.clone(),
495                is_type_only: r.is_type_only,
496            })
497            .collect(),
498        dynamic_imports: module
499            .dynamic_imports
500            .iter()
501            .map(|d| CachedDynamicImport {
502                source: d.source.clone(),
503                span_start: d.span.start,
504                span_end: d.span.end,
505                destructured_names: d.destructured_names.clone(),
506                local_name: d.local_name.clone(),
507            })
508            .collect(),
509        require_calls: module
510            .require_calls
511            .iter()
512            .map(|r| CachedRequireCall {
513                source: r.source.clone(),
514                span_start: r.span.start,
515                span_end: r.span.end,
516                destructured_names: r.destructured_names.clone(),
517                local_name: r.local_name.clone(),
518            })
519            .collect(),
520        member_accesses: module.member_accesses.clone(),
521        whole_object_uses: module.whole_object_uses.clone(),
522        dynamic_import_patterns: module
523            .dynamic_import_patterns
524            .iter()
525            .map(|p| CachedDynamicImportPattern {
526                prefix: p.prefix.clone(),
527                suffix: p.suffix.clone(),
528                span_start: p.span.start,
529                span_end: p.span.end,
530            })
531            .collect(),
532        has_cjs_exports: module.has_cjs_exports,
533        suppressions: module
534            .suppressions
535            .iter()
536            .map(|s| CachedSuppression {
537                line: s.line,
538                kind: s.kind.map_or(0, |k| k.to_discriminant()),
539            })
540            .collect(),
541        line_offsets: module.line_offsets.clone(),
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use crate::*;
549    use fallow_types::discover::FileId;
550
551    #[test]
552    fn cache_store_new_is_empty() {
553        let store = CacheStore::new();
554        assert!(store.is_empty());
555        assert_eq!(store.len(), 0);
556    }
557
558    #[test]
559    fn cache_store_default_is_empty() {
560        let store = CacheStore::default();
561        assert!(store.is_empty());
562    }
563
564    #[test]
565    fn cache_store_insert_and_get() {
566        let mut store = CacheStore::new();
567        let module = CachedModule {
568            content_hash: 42,
569            mtime_secs: 0,
570            file_size: 0,
571            exports: vec![],
572            imports: vec![],
573            re_exports: vec![],
574            dynamic_imports: vec![],
575            require_calls: vec![],
576            member_accesses: vec![],
577            whole_object_uses: vec![],
578            dynamic_import_patterns: vec![],
579            has_cjs_exports: false,
580            suppressions: vec![],
581            line_offsets: vec![],
582        };
583        store.insert(Path::new("test.ts"), module);
584        assert_eq!(store.len(), 1);
585        assert!(!store.is_empty());
586        assert!(store.get(Path::new("test.ts"), 42).is_some());
587    }
588
589    #[test]
590    fn cache_store_hash_mismatch_returns_none() {
591        let mut store = CacheStore::new();
592        let module = CachedModule {
593            content_hash: 42,
594            mtime_secs: 0,
595            file_size: 0,
596            exports: vec![],
597            imports: vec![],
598            re_exports: vec![],
599            dynamic_imports: vec![],
600            require_calls: vec![],
601            member_accesses: vec![],
602            whole_object_uses: vec![],
603            dynamic_import_patterns: vec![],
604            has_cjs_exports: false,
605            suppressions: vec![],
606            line_offsets: vec![],
607        };
608        store.insert(Path::new("test.ts"), module);
609        assert!(store.get(Path::new("test.ts"), 99).is_none());
610    }
611
612    #[test]
613    fn cache_store_missing_key_returns_none() {
614        let store = CacheStore::new();
615        assert!(store.get(Path::new("nonexistent.ts"), 42).is_none());
616    }
617
618    #[test]
619    fn cache_store_overwrite_entry() {
620        let mut store = CacheStore::new();
621        let m1 = CachedModule {
622            content_hash: 1,
623            mtime_secs: 0,
624            file_size: 0,
625            exports: vec![],
626            imports: vec![],
627            re_exports: vec![],
628            dynamic_imports: vec![],
629            require_calls: vec![],
630            member_accesses: vec![],
631            whole_object_uses: vec![],
632            dynamic_import_patterns: vec![],
633            has_cjs_exports: false,
634            suppressions: vec![],
635            line_offsets: vec![],
636        };
637        let m2 = CachedModule {
638            content_hash: 2,
639            mtime_secs: 0,
640            file_size: 0,
641            exports: vec![],
642            imports: vec![],
643            re_exports: vec![],
644            dynamic_imports: vec![],
645            require_calls: vec![],
646            member_accesses: vec![],
647            whole_object_uses: vec![],
648            dynamic_import_patterns: vec![],
649            has_cjs_exports: false,
650            suppressions: vec![],
651            line_offsets: vec![],
652        };
653        store.insert(Path::new("test.ts"), m1);
654        store.insert(Path::new("test.ts"), m2);
655        assert_eq!(store.len(), 1);
656        assert!(store.get(Path::new("test.ts"), 1).is_none());
657        assert!(store.get(Path::new("test.ts"), 2).is_some());
658    }
659
660    #[test]
661    fn module_to_cached_roundtrip_named_export() {
662        let module = ModuleInfo {
663            file_id: FileId(0),
664            exports: vec![ExportInfo {
665                name: ExportName::Named("foo".to_string()),
666                local_name: Some("foo".to_string()),
667                is_type_only: false,
668                span: Span::new(10, 20),
669                members: vec![],
670            }],
671            imports: vec![],
672            re_exports: vec![],
673            dynamic_imports: vec![],
674            require_calls: vec![],
675            member_accesses: vec![],
676            whole_object_uses: vec![],
677            dynamic_import_patterns: vec![],
678            has_cjs_exports: false,
679            content_hash: 123,
680            suppressions: vec![],
681            line_offsets: vec![],
682        };
683
684        let cached = module_to_cached(&module, 0, 0);
685        let restored = cached_to_module(&cached, FileId(0));
686
687        assert_eq!(restored.exports.len(), 1);
688        assert_eq!(
689            restored.exports[0].name,
690            ExportName::Named("foo".to_string())
691        );
692        assert!(!restored.exports[0].is_type_only);
693        assert_eq!(restored.exports[0].span.start, 10);
694        assert_eq!(restored.exports[0].span.end, 20);
695        assert_eq!(restored.content_hash, 123);
696    }
697
698    #[test]
699    fn module_to_cached_roundtrip_default_export() {
700        let module = ModuleInfo {
701            file_id: FileId(0),
702            exports: vec![ExportInfo {
703                name: ExportName::Default,
704                local_name: None,
705                is_type_only: false,
706                span: Span::new(0, 10),
707                members: vec![],
708            }],
709            imports: vec![],
710            re_exports: vec![],
711            dynamic_imports: vec![],
712            require_calls: vec![],
713            member_accesses: vec![],
714            whole_object_uses: vec![],
715            dynamic_import_patterns: vec![],
716            has_cjs_exports: false,
717            content_hash: 456,
718            suppressions: vec![],
719            line_offsets: vec![],
720        };
721
722        let cached = module_to_cached(&module, 0, 0);
723        let restored = cached_to_module(&cached, FileId(0));
724
725        assert_eq!(restored.exports[0].name, ExportName::Default);
726    }
727
728    #[test]
729    fn module_to_cached_roundtrip_imports() {
730        let module = ModuleInfo {
731            file_id: FileId(0),
732            exports: vec![],
733            imports: vec![
734                ImportInfo {
735                    source: "./utils".to_string(),
736                    imported_name: ImportedName::Named("foo".to_string()),
737                    local_name: "foo".to_string(),
738                    is_type_only: false,
739                    span: Span::new(0, 10),
740                },
741                ImportInfo {
742                    source: "react".to_string(),
743                    imported_name: ImportedName::Default,
744                    local_name: "React".to_string(),
745                    is_type_only: false,
746                    span: Span::new(15, 30),
747                },
748                ImportInfo {
749                    source: "./all".to_string(),
750                    imported_name: ImportedName::Namespace,
751                    local_name: "all".to_string(),
752                    is_type_only: false,
753                    span: Span::new(35, 50),
754                },
755                ImportInfo {
756                    source: "./styles.css".to_string(),
757                    imported_name: ImportedName::SideEffect,
758                    local_name: String::new(),
759                    is_type_only: false,
760                    span: Span::new(55, 70),
761                },
762            ],
763            re_exports: vec![],
764            dynamic_imports: vec![],
765            require_calls: vec![],
766            member_accesses: vec![],
767            whole_object_uses: vec![],
768            dynamic_import_patterns: vec![],
769            has_cjs_exports: false,
770            content_hash: 789,
771            suppressions: vec![],
772            line_offsets: vec![],
773        };
774
775        let cached = module_to_cached(&module, 0, 0);
776        let restored = cached_to_module(&cached, FileId(0));
777
778        assert_eq!(restored.imports.len(), 4);
779        assert_eq!(
780            restored.imports[0].imported_name,
781            ImportedName::Named("foo".to_string())
782        );
783        assert_eq!(restored.imports[0].span.start, 0);
784        assert_eq!(restored.imports[0].span.end, 10);
785        assert_eq!(restored.imports[1].imported_name, ImportedName::Default);
786        assert_eq!(restored.imports[1].span.start, 15);
787        assert_eq!(restored.imports[1].span.end, 30);
788        assert_eq!(restored.imports[2].imported_name, ImportedName::Namespace);
789        assert_eq!(restored.imports[2].span.start, 35);
790        assert_eq!(restored.imports[2].span.end, 50);
791        assert_eq!(restored.imports[3].imported_name, ImportedName::SideEffect);
792        assert_eq!(restored.imports[3].span.start, 55);
793        assert_eq!(restored.imports[3].span.end, 70);
794    }
795
796    #[test]
797    fn module_to_cached_roundtrip_re_exports() {
798        let module = ModuleInfo {
799            file_id: FileId(0),
800            exports: vec![],
801            imports: vec![],
802            re_exports: vec![ReExportInfo {
803                source: "./module".to_string(),
804                imported_name: "foo".to_string(),
805                exported_name: "bar".to_string(),
806                is_type_only: true,
807            }],
808            dynamic_imports: vec![],
809            require_calls: vec![],
810            member_accesses: vec![],
811            whole_object_uses: vec![],
812            dynamic_import_patterns: vec![],
813            has_cjs_exports: false,
814            content_hash: 0,
815            suppressions: vec![],
816            line_offsets: vec![],
817        };
818
819        let cached = module_to_cached(&module, 0, 0);
820        let restored = cached_to_module(&cached, FileId(0));
821
822        assert_eq!(restored.re_exports.len(), 1);
823        assert_eq!(restored.re_exports[0].source, "./module");
824        assert_eq!(restored.re_exports[0].imported_name, "foo");
825        assert_eq!(restored.re_exports[0].exported_name, "bar");
826        assert!(restored.re_exports[0].is_type_only);
827    }
828
829    #[test]
830    fn module_to_cached_roundtrip_dynamic_imports() {
831        let module = ModuleInfo {
832            file_id: FileId(0),
833            exports: vec![],
834            imports: vec![],
835            re_exports: vec![],
836            dynamic_imports: vec![DynamicImportInfo {
837                source: "./lazy".to_string(),
838                span: Span::new(0, 10),
839                destructured_names: Vec::new(),
840                local_name: None,
841            }],
842            require_calls: vec![RequireCallInfo {
843                source: "fs".to_string(),
844                span: Span::new(15, 25),
845                destructured_names: Vec::new(),
846                local_name: None,
847            }],
848            member_accesses: vec![MemberAccess {
849                object: "Status".to_string(),
850                member: "Active".to_string(),
851            }],
852            whole_object_uses: vec![],
853            dynamic_import_patterns: vec![],
854            has_cjs_exports: true,
855            content_hash: 0,
856            suppressions: vec![],
857            line_offsets: vec![],
858        };
859
860        let cached = module_to_cached(&module, 0, 0);
861        let restored = cached_to_module(&cached, FileId(0));
862
863        assert_eq!(restored.dynamic_imports.len(), 1);
864        assert_eq!(restored.dynamic_imports[0].source, "./lazy");
865        assert_eq!(restored.dynamic_imports[0].span.start, 0);
866        assert_eq!(restored.dynamic_imports[0].span.end, 10);
867        assert_eq!(restored.require_calls.len(), 1);
868        assert_eq!(restored.require_calls[0].source, "fs");
869        assert_eq!(restored.require_calls[0].span.start, 15);
870        assert_eq!(restored.require_calls[0].span.end, 25);
871        assert_eq!(restored.member_accesses.len(), 1);
872        assert_eq!(restored.member_accesses[0].object, "Status");
873        assert_eq!(restored.member_accesses[0].member, "Active");
874        assert!(restored.has_cjs_exports);
875    }
876
877    #[test]
878    fn module_to_cached_roundtrip_members() {
879        let module = ModuleInfo {
880            file_id: FileId(0),
881            exports: vec![ExportInfo {
882                name: ExportName::Named("Color".to_string()),
883                local_name: Some("Color".to_string()),
884                is_type_only: false,
885                span: Span::new(0, 50),
886                members: vec![
887                    MemberInfo {
888                        name: "Red".to_string(),
889                        kind: MemberKind::EnumMember,
890                        span: Span::new(10, 15),
891                        has_decorator: false,
892                    },
893                    MemberInfo {
894                        name: "greet".to_string(),
895                        kind: MemberKind::ClassMethod,
896                        span: Span::new(20, 30),
897                        has_decorator: false,
898                    },
899                    MemberInfo {
900                        name: "name".to_string(),
901                        kind: MemberKind::ClassProperty,
902                        span: Span::new(35, 45),
903                        has_decorator: false,
904                    },
905                ],
906            }],
907            imports: vec![],
908            re_exports: vec![],
909            dynamic_imports: vec![],
910            require_calls: vec![],
911            member_accesses: vec![],
912            whole_object_uses: vec![],
913            dynamic_import_patterns: vec![],
914            has_cjs_exports: false,
915            content_hash: 0,
916            suppressions: vec![],
917            line_offsets: vec![],
918        };
919
920        let cached = module_to_cached(&module, 0, 0);
921        let restored = cached_to_module(&cached, FileId(0));
922
923        assert_eq!(restored.exports[0].members.len(), 3);
924        assert_eq!(restored.exports[0].members[0].kind, MemberKind::EnumMember);
925        assert_eq!(restored.exports[0].members[1].kind, MemberKind::ClassMethod);
926        assert_eq!(
927            restored.exports[0].members[2].kind,
928            MemberKind::ClassProperty
929        );
930    }
931
932    #[test]
933    fn cache_load_nonexistent_returns_none() {
934        let result = CacheStore::load(Path::new("/nonexistent/path"));
935        assert!(result.is_none());
936    }
937
938    /// Create a unique temporary directory for cache tests.
939    fn test_cache_dir(name: &str) -> std::path::PathBuf {
940        let dir = std::env::temp_dir()
941            .join("fallow_cache_tests")
942            .join(name)
943            .join(format!("{}", std::process::id()));
944        // Clean up any leftover from previous runs
945        let _ = std::fs::remove_dir_all(&dir);
946        std::fs::create_dir_all(&dir).unwrap();
947        dir
948    }
949
950    #[test]
951    fn cache_save_and_load_roundtrip() {
952        let dir = test_cache_dir("roundtrip");
953        let mut store = CacheStore::new();
954        let module = CachedModule {
955            content_hash: 42,
956            mtime_secs: 0,
957            file_size: 0,
958            exports: vec![],
959            imports: vec![],
960            re_exports: vec![],
961            dynamic_imports: vec![],
962            require_calls: vec![],
963            member_accesses: vec![],
964            whole_object_uses: vec![],
965            dynamic_import_patterns: vec![],
966            has_cjs_exports: false,
967            suppressions: vec![],
968            line_offsets: vec![],
969        };
970        store.insert(Path::new("test.ts"), module);
971        store.save(&dir).unwrap();
972
973        let loaded = CacheStore::load(&dir);
974        assert!(loaded.is_some());
975        let loaded = loaded.unwrap();
976        assert_eq!(loaded.len(), 1);
977        assert!(loaded.get(Path::new("test.ts"), 42).is_some());
978
979        let _ = std::fs::remove_dir_all(&dir);
980    }
981
982    #[test]
983    fn cache_version_mismatch_returns_none() {
984        let dir = test_cache_dir("version_mismatch");
985        let mut store = CacheStore::new();
986        let module = CachedModule {
987            content_hash: 42,
988            mtime_secs: 0,
989            file_size: 0,
990            exports: vec![],
991            imports: vec![],
992            re_exports: vec![],
993            dynamic_imports: vec![],
994            require_calls: vec![],
995            member_accesses: vec![],
996            whole_object_uses: vec![],
997            dynamic_import_patterns: vec![],
998            has_cjs_exports: false,
999            suppressions: vec![],
1000            line_offsets: vec![],
1001        };
1002        store.insert(Path::new("test.ts"), module);
1003        store.save(&dir).unwrap();
1004
1005        // Verify the cache loads correctly before tampering
1006        assert!(CacheStore::load(&dir).is_some());
1007
1008        // Read raw bytes and modify the version field.
1009        // With bincode standard config, u32 is varint-encoded.
1010        // The version (CACHE_VERSION) is the first encoded field.
1011        // Replace the first byte with a different version value (e.g., 255)
1012        // to simulate a version mismatch.
1013        let cache_file = dir.join("cache.bin");
1014        let mut data = std::fs::read(&cache_file).unwrap();
1015        assert!(!data.is_empty());
1016        data[0] = 255; // Corrupt the version byte
1017        std::fs::write(&cache_file, &data).unwrap();
1018
1019        // Loading should return None due to version mismatch
1020        let result = CacheStore::load(&dir);
1021        assert!(result.is_none());
1022
1023        let _ = std::fs::remove_dir_all(&dir);
1024    }
1025
1026    #[test]
1027    fn module_to_cached_roundtrip_type_only_import() {
1028        let module = ModuleInfo {
1029            file_id: FileId(0),
1030            exports: vec![],
1031            imports: vec![ImportInfo {
1032                source: "./types".to_string(),
1033                imported_name: ImportedName::Named("Foo".to_string()),
1034                local_name: "Foo".to_string(),
1035                is_type_only: true,
1036                span: Span::new(0, 10),
1037            }],
1038            re_exports: vec![],
1039            dynamic_imports: vec![],
1040            require_calls: vec![],
1041            member_accesses: vec![],
1042            whole_object_uses: vec![],
1043            dynamic_import_patterns: vec![],
1044            has_cjs_exports: false,
1045            content_hash: 0,
1046            suppressions: vec![],
1047            line_offsets: vec![],
1048        };
1049
1050        let cached = module_to_cached(&module, 0, 0);
1051        let restored = cached_to_module(&cached, FileId(0));
1052
1053        assert!(restored.imports[0].is_type_only);
1054        assert_eq!(restored.imports[0].span.start, 0);
1055        assert_eq!(restored.imports[0].span.end, 10);
1056    }
1057
1058    #[test]
1059    fn get_by_path_only_returns_entry_regardless_of_hash() {
1060        let mut store = CacheStore::new();
1061        let module = CachedModule {
1062            content_hash: 42,
1063            mtime_secs: 0,
1064            file_size: 0,
1065            exports: vec![],
1066            imports: vec![],
1067            re_exports: vec![],
1068            dynamic_imports: vec![],
1069            require_calls: vec![],
1070            member_accesses: vec![],
1071            whole_object_uses: vec![],
1072            dynamic_import_patterns: vec![],
1073            has_cjs_exports: false,
1074            suppressions: vec![],
1075            line_offsets: vec![],
1076        };
1077        store.insert(Path::new("test.ts"), module);
1078
1079        // get_by_path_only should return the entry without checking hash
1080        let result = store.get_by_path_only(Path::new("test.ts"));
1081        assert!(result.is_some());
1082        assert_eq!(result.unwrap().content_hash, 42);
1083    }
1084
1085    #[test]
1086    fn get_by_path_only_returns_none_for_missing() {
1087        let store = CacheStore::new();
1088        assert!(
1089            store
1090                .get_by_path_only(Path::new("nonexistent.ts"))
1091                .is_none()
1092        );
1093    }
1094
1095    #[test]
1096    fn retain_paths_removes_stale_entries() {
1097        use fallow_types::discover::DiscoveredFile;
1098        use std::path::PathBuf;
1099
1100        let mut store = CacheStore::new();
1101        let m = || CachedModule {
1102            content_hash: 1,
1103            mtime_secs: 0,
1104            file_size: 0,
1105            exports: vec![],
1106            imports: vec![],
1107            re_exports: vec![],
1108            dynamic_imports: vec![],
1109            require_calls: vec![],
1110            member_accesses: vec![],
1111            whole_object_uses: vec![],
1112            dynamic_import_patterns: vec![],
1113            has_cjs_exports: false,
1114            suppressions: vec![],
1115            line_offsets: vec![],
1116        };
1117
1118        store.insert(Path::new("/project/a.ts"), m());
1119        store.insert(Path::new("/project/b.ts"), m());
1120        store.insert(Path::new("/project/c.ts"), m());
1121        assert_eq!(store.len(), 3);
1122
1123        // Only a.ts and c.ts still exist in the project
1124        let files = vec![
1125            DiscoveredFile {
1126                id: FileId(0),
1127                path: PathBuf::from("/project/a.ts"),
1128                size_bytes: 100,
1129            },
1130            DiscoveredFile {
1131                id: FileId(1),
1132                path: PathBuf::from("/project/c.ts"),
1133                size_bytes: 50,
1134            },
1135        ];
1136
1137        store.retain_paths(&files);
1138        assert_eq!(store.len(), 2);
1139        assert!(store.get_by_path_only(Path::new("/project/a.ts")).is_some());
1140        assert!(store.get_by_path_only(Path::new("/project/b.ts")).is_none());
1141        assert!(store.get_by_path_only(Path::new("/project/c.ts")).is_some());
1142    }
1143
1144    #[test]
1145    fn retain_paths_with_empty_files_clears_cache() {
1146        let mut store = CacheStore::new();
1147        let m = CachedModule {
1148            content_hash: 1,
1149            mtime_secs: 0,
1150            file_size: 0,
1151            exports: vec![],
1152            imports: vec![],
1153            re_exports: vec![],
1154            dynamic_imports: vec![],
1155            require_calls: vec![],
1156            member_accesses: vec![],
1157            whole_object_uses: vec![],
1158            dynamic_import_patterns: vec![],
1159            has_cjs_exports: false,
1160            suppressions: vec![],
1161            line_offsets: vec![],
1162        };
1163        store.insert(Path::new("a.ts"), m);
1164        assert_eq!(store.len(), 1);
1165
1166        store.retain_paths(&[]);
1167        assert!(store.is_empty());
1168    }
1169
1170    #[test]
1171    fn get_by_metadata_returns_entry_on_match() {
1172        let mut store = CacheStore::new();
1173        let module = CachedModule {
1174            content_hash: 42,
1175            mtime_secs: 1000,
1176            file_size: 500,
1177            exports: vec![],
1178            imports: vec![],
1179            re_exports: vec![],
1180            dynamic_imports: vec![],
1181            require_calls: vec![],
1182            member_accesses: vec![],
1183            whole_object_uses: vec![],
1184            dynamic_import_patterns: vec![],
1185            has_cjs_exports: false,
1186            suppressions: vec![],
1187            line_offsets: vec![],
1188        };
1189        store.insert(Path::new("test.ts"), module);
1190
1191        let result = store.get_by_metadata(Path::new("test.ts"), 1000, 500);
1192        assert!(result.is_some());
1193        assert_eq!(result.unwrap().content_hash, 42);
1194    }
1195
1196    #[test]
1197    fn get_by_metadata_returns_none_on_mtime_mismatch() {
1198        let mut store = CacheStore::new();
1199        let module = CachedModule {
1200            content_hash: 42,
1201            mtime_secs: 1000,
1202            file_size: 500,
1203            exports: vec![],
1204            imports: vec![],
1205            re_exports: vec![],
1206            dynamic_imports: vec![],
1207            require_calls: vec![],
1208            member_accesses: vec![],
1209            whole_object_uses: vec![],
1210            dynamic_import_patterns: vec![],
1211            has_cjs_exports: false,
1212            suppressions: vec![],
1213            line_offsets: vec![],
1214        };
1215        store.insert(Path::new("test.ts"), module);
1216
1217        assert!(
1218            store
1219                .get_by_metadata(Path::new("test.ts"), 2000, 500)
1220                .is_none()
1221        );
1222    }
1223
1224    #[test]
1225    fn get_by_metadata_returns_none_on_size_mismatch() {
1226        let mut store = CacheStore::new();
1227        let module = CachedModule {
1228            content_hash: 42,
1229            mtime_secs: 1000,
1230            file_size: 500,
1231            exports: vec![],
1232            imports: vec![],
1233            re_exports: vec![],
1234            dynamic_imports: vec![],
1235            require_calls: vec![],
1236            member_accesses: vec![],
1237            whole_object_uses: vec![],
1238            dynamic_import_patterns: vec![],
1239            has_cjs_exports: false,
1240            suppressions: vec![],
1241            line_offsets: vec![],
1242        };
1243        store.insert(Path::new("test.ts"), module);
1244
1245        assert!(
1246            store
1247                .get_by_metadata(Path::new("test.ts"), 1000, 999)
1248                .is_none()
1249        );
1250    }
1251
1252    #[test]
1253    fn get_by_metadata_returns_none_for_zero_mtime() {
1254        let mut store = CacheStore::new();
1255        let module = CachedModule {
1256            content_hash: 42,
1257            mtime_secs: 0,
1258            file_size: 500,
1259            exports: vec![],
1260            imports: vec![],
1261            re_exports: vec![],
1262            dynamic_imports: vec![],
1263            require_calls: vec![],
1264            member_accesses: vec![],
1265            whole_object_uses: vec![],
1266            dynamic_import_patterns: vec![],
1267            has_cjs_exports: false,
1268            suppressions: vec![],
1269            line_offsets: vec![],
1270        };
1271        store.insert(Path::new("test.ts"), module);
1272
1273        // Zero mtime should never match (falls through to content hash check)
1274        assert!(
1275            store
1276                .get_by_metadata(Path::new("test.ts"), 0, 500)
1277                .is_none()
1278        );
1279    }
1280
1281    #[test]
1282    fn get_by_metadata_returns_none_for_missing_file() {
1283        let store = CacheStore::new();
1284        assert!(
1285            store
1286                .get_by_metadata(Path::new("nonexistent.ts"), 1000, 500)
1287                .is_none()
1288        );
1289    }
1290
1291    #[test]
1292    fn module_to_cached_stores_mtime_and_size() {
1293        let module = ModuleInfo {
1294            file_id: FileId(0),
1295            exports: vec![],
1296            imports: vec![],
1297            re_exports: vec![],
1298            dynamic_imports: vec![],
1299            require_calls: vec![],
1300            member_accesses: vec![],
1301            whole_object_uses: vec![],
1302            dynamic_import_patterns: vec![],
1303            has_cjs_exports: false,
1304            content_hash: 42,
1305            suppressions: vec![],
1306            line_offsets: vec![],
1307        };
1308
1309        let cached = module_to_cached(&module, 12345, 6789);
1310        assert_eq!(cached.mtime_secs, 12345);
1311        assert_eq!(cached.file_size, 6789);
1312        assert_eq!(cached.content_hash, 42);
1313    }
1314
1315    #[test]
1316    fn module_to_cached_roundtrip_line_offsets() {
1317        let module = ModuleInfo {
1318            file_id: FileId(0),
1319            exports: vec![],
1320            imports: vec![],
1321            re_exports: vec![],
1322            dynamic_imports: vec![],
1323            require_calls: vec![],
1324            member_accesses: vec![],
1325            whole_object_uses: vec![],
1326            dynamic_import_patterns: vec![],
1327            has_cjs_exports: false,
1328            content_hash: 0,
1329            suppressions: vec![],
1330            line_offsets: vec![0, 15, 30, 45],
1331        };
1332        let cached = module_to_cached(&module, 0, 0);
1333        let restored = cached_to_module(&cached, FileId(0));
1334        assert_eq!(restored.line_offsets, vec![0, 15, 30, 45]);
1335    }
1336}