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