Skip to main content

fallow_core/
cache.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use bincode::{Decode, Encode};
5
6use oxc_span::Span;
7
8use crate::extract::{ExportName, MemberAccess, MemberKind};
9
10/// Cache version — bump when the cache format changes.
11const CACHE_VERSION: u32 = 7;
12
13/// Maximum cache file size to deserialize (256 MB).
14const MAX_CACHE_SIZE: usize = 256 * 1024 * 1024;
15
16/// Cached module information stored on disk.
17#[derive(Debug, Encode, Decode)]
18pub struct CacheStore {
19    version: u32,
20    /// Map from file path to cached module data.
21    entries: HashMap<String, CachedModule>,
22}
23
24/// Cached data for a single module.
25#[derive(Debug, Clone, Encode, Decode)]
26pub struct CachedModule {
27    /// xxh3 hash of the file content.
28    pub content_hash: u64,
29    /// Exported symbols.
30    pub exports: Vec<CachedExport>,
31    /// Import specifiers.
32    pub imports: Vec<CachedImport>,
33    /// Re-export specifiers.
34    pub re_exports: Vec<CachedReExport>,
35    /// Dynamic import specifiers.
36    pub dynamic_imports: Vec<CachedDynamicImport>,
37    /// Require() specifiers.
38    pub require_calls: Vec<CachedRequireCall>,
39    /// Static member accesses (e.g., `Status.Active`).
40    pub member_accesses: Vec<MemberAccess>,
41    /// Identifiers used as whole objects (Object.values, for..in, spread, etc.).
42    pub whole_object_uses: Vec<String>,
43    /// Dynamic import patterns with partial static resolution.
44    pub dynamic_import_patterns: Vec<CachedDynamicImportPattern>,
45    /// Whether this module uses CJS exports.
46    pub has_cjs_exports: bool,
47    /// Inline suppression directives.
48    pub suppressions: Vec<CachedSuppression>,
49}
50
51/// Cached suppression directive.
52#[derive(Debug, Clone, Encode, Decode)]
53pub struct CachedSuppression {
54    /// 1-based line this suppression applies to. 0 = file-wide.
55    pub line: u32,
56    /// 0 = suppress all, 1-10 = IssueKind discriminant.
57    pub kind: u8,
58}
59
60#[derive(Debug, Clone, Encode, Decode)]
61pub struct CachedExport {
62    pub name: String,
63    pub is_default: bool,
64    pub is_type_only: bool,
65    pub local_name: Option<String>,
66    pub span_start: u32,
67    pub span_end: u32,
68    pub members: Vec<CachedMember>,
69}
70
71/// Import kind discriminant for `CachedImport`.
72/// 0 = Named, 1 = Default, 2 = Namespace, 3 = SideEffect.
73const IMPORT_KIND_NAMED: u8 = 0;
74const IMPORT_KIND_DEFAULT: u8 = 1;
75const IMPORT_KIND_NAMESPACE: u8 = 2;
76const IMPORT_KIND_SIDE_EFFECT: u8 = 3;
77
78#[derive(Debug, Clone, Encode, Decode)]
79pub struct CachedImport {
80    pub source: String,
81    /// For Named imports, the imported symbol name. Empty for other kinds.
82    pub imported_name: String,
83    pub local_name: String,
84    pub is_type_only: bool,
85    /// Import kind: 0=Named, 1=Default, 2=Namespace, 3=SideEffect.
86    pub kind: u8,
87    pub span_start: u32,
88    pub span_end: u32,
89}
90
91#[derive(Debug, Clone, Encode, Decode)]
92pub struct CachedDynamicImport {
93    pub source: String,
94    pub span_start: u32,
95    pub span_end: u32,
96    pub destructured_names: Vec<String>,
97    pub local_name: Option<String>,
98}
99
100#[derive(Debug, Clone, Encode, Decode)]
101pub struct CachedRequireCall {
102    pub source: String,
103    pub span_start: u32,
104    pub span_end: u32,
105    pub destructured_names: Vec<String>,
106    pub local_name: Option<String>,
107}
108
109#[derive(Debug, Clone, Encode, Decode)]
110pub struct CachedReExport {
111    pub source: String,
112    pub imported_name: String,
113    pub exported_name: String,
114    pub is_type_only: bool,
115}
116
117#[derive(Debug, Clone, Encode, Decode)]
118pub struct CachedMember {
119    pub name: String,
120    pub kind: String,
121    pub span_start: u32,
122    pub span_end: u32,
123}
124
125#[derive(Debug, Clone, Encode, Decode)]
126pub struct CachedDynamicImportPattern {
127    pub prefix: String,
128    pub suffix: Option<String>,
129    pub span_start: u32,
130    pub span_end: u32,
131}
132
133impl CacheStore {
134    /// Create a new empty cache.
135    pub fn new() -> Self {
136        Self {
137            version: CACHE_VERSION,
138            entries: HashMap::new(),
139        }
140    }
141
142    /// Load cache from disk.
143    pub fn load(cache_dir: &Path) -> Option<Self> {
144        let cache_file = cache_dir.join("cache.bin");
145        let data = std::fs::read(&cache_file).ok()?;
146        if data.len() > MAX_CACHE_SIZE {
147            tracing::warn!(
148                size_mb = data.len() / (1024 * 1024),
149                "Cache file exceeds size limit, ignoring"
150            );
151            return None;
152        }
153        let (store, _): (Self, usize) =
154            bincode::decode_from_slice(&data, bincode::config::standard()).ok()?;
155        if store.version != CACHE_VERSION {
156            return None;
157        }
158        Some(store)
159    }
160
161    /// Save cache to disk.
162    pub fn save(&self, cache_dir: &Path) -> Result<(), String> {
163        std::fs::create_dir_all(cache_dir)
164            .map_err(|e| format!("Failed to create cache dir: {e}"))?;
165        let cache_file = cache_dir.join("cache.bin");
166        let data = bincode::encode_to_vec(self, bincode::config::standard())
167            .map_err(|e| format!("Failed to serialize cache: {e}"))?;
168        std::fs::write(&cache_file, data).map_err(|e| format!("Failed to write cache: {e}"))?;
169        Ok(())
170    }
171
172    /// Look up a cached module by path and content hash.
173    /// Returns None if not cached or hash mismatch.
174    pub fn get(&self, path: &Path, content_hash: u64) -> Option<&CachedModule> {
175        let key = path.to_string_lossy().to_string();
176        let entry = self.entries.get(&key)?;
177        if entry.content_hash == content_hash {
178            Some(entry)
179        } else {
180            None
181        }
182    }
183
184    /// Insert or update a cached module.
185    pub fn insert(&mut self, path: &Path, module: CachedModule) {
186        let key = path.to_string_lossy().to_string();
187        self.entries.insert(key, module);
188    }
189
190    /// Number of cached entries.
191    pub fn len(&self) -> usize {
192        self.entries.len()
193    }
194
195    /// Whether cache is empty.
196    pub fn is_empty(&self) -> bool {
197        self.entries.is_empty()
198    }
199}
200
201impl Default for CacheStore {
202    fn default() -> Self {
203        Self::new()
204    }
205}
206
207/// Reconstruct a ModuleInfo from a CachedModule.
208pub fn cached_to_module(
209    cached: &CachedModule,
210    file_id: crate::discover::FileId,
211) -> crate::extract::ModuleInfo {
212    use crate::extract::*;
213
214    let exports = cached
215        .exports
216        .iter()
217        .map(|e| ExportInfo {
218            name: if e.is_default {
219                ExportName::Default
220            } else {
221                ExportName::Named(e.name.clone())
222            },
223            local_name: e.local_name.clone(),
224            is_type_only: e.is_type_only,
225            span: Span::new(e.span_start, e.span_end),
226            members: e
227                .members
228                .iter()
229                .map(|m| MemberInfo {
230                    name: m.name.clone(),
231                    kind: match m.kind.as_str() {
232                        "enum" => MemberKind::EnumMember,
233                        "method" => MemberKind::ClassMethod,
234                        "property" => MemberKind::ClassProperty,
235                        other => {
236                            tracing::warn!(
237                                kind = other,
238                                "Unknown cached member kind, defaulting to ClassProperty"
239                            );
240                            MemberKind::ClassProperty
241                        }
242                    },
243                    span: Span::new(m.span_start, m.span_end),
244                })
245                .collect(),
246        })
247        .collect();
248
249    let imports = cached
250        .imports
251        .iter()
252        .map(|i| ImportInfo {
253            source: i.source.clone(),
254            imported_name: match i.kind {
255                IMPORT_KIND_DEFAULT => ImportedName::Default,
256                IMPORT_KIND_NAMESPACE => ImportedName::Namespace,
257                IMPORT_KIND_SIDE_EFFECT => ImportedName::SideEffect,
258                // IMPORT_KIND_NAMED (0) and any unknown value default to Named
259                _ => ImportedName::Named(i.imported_name.clone()),
260            },
261            local_name: i.local_name.clone(),
262            is_type_only: i.is_type_only,
263            span: Span::new(i.span_start, i.span_end),
264        })
265        .collect();
266
267    let re_exports = cached
268        .re_exports
269        .iter()
270        .map(|r| ReExportInfo {
271            source: r.source.clone(),
272            imported_name: r.imported_name.clone(),
273            exported_name: r.exported_name.clone(),
274            is_type_only: r.is_type_only,
275        })
276        .collect();
277
278    let dynamic_imports = cached
279        .dynamic_imports
280        .iter()
281        .map(|d| DynamicImportInfo {
282            source: d.source.clone(),
283            span: Span::new(d.span_start, d.span_end),
284            destructured_names: d.destructured_names.clone(),
285            local_name: d.local_name.clone(),
286        })
287        .collect();
288
289    let require_calls = cached
290        .require_calls
291        .iter()
292        .map(|r| RequireCallInfo {
293            source: r.source.clone(),
294            span: Span::new(r.span_start, r.span_end),
295            destructured_names: r.destructured_names.clone(),
296            local_name: r.local_name.clone(),
297        })
298        .collect();
299
300    let dynamic_import_patterns = cached
301        .dynamic_import_patterns
302        .iter()
303        .map(|p| crate::extract::DynamicImportPattern {
304            prefix: p.prefix.clone(),
305            suffix: p.suffix.clone(),
306            span: Span::new(p.span_start, p.span_end),
307        })
308        .collect();
309
310    let suppressions = cached
311        .suppressions
312        .iter()
313        .map(|s| crate::suppress::Suppression {
314            line: s.line,
315            kind: if s.kind == 0 {
316                None
317            } else {
318                crate::suppress::IssueKind::from_discriminant(s.kind)
319            },
320        })
321        .collect();
322
323    ModuleInfo {
324        file_id,
325        exports,
326        imports,
327        re_exports,
328        dynamic_imports,
329        dynamic_import_patterns,
330        require_calls,
331        member_accesses: cached.member_accesses.clone(),
332        whole_object_uses: cached.whole_object_uses.clone(),
333        has_cjs_exports: cached.has_cjs_exports,
334        content_hash: cached.content_hash,
335        suppressions,
336    }
337}
338
339/// Convert a ModuleInfo to a CachedModule for storage.
340pub fn module_to_cached(module: &crate::extract::ModuleInfo) -> CachedModule {
341    CachedModule {
342        content_hash: module.content_hash,
343        exports: module
344            .exports
345            .iter()
346            .map(|e| CachedExport {
347                name: match &e.name {
348                    ExportName::Named(n) => n.clone(),
349                    ExportName::Default => "default".to_string(),
350                },
351                is_default: matches!(e.name, ExportName::Default),
352                is_type_only: e.is_type_only,
353                local_name: e.local_name.clone(),
354                span_start: e.span.start,
355                span_end: e.span.end,
356                members: e
357                    .members
358                    .iter()
359                    .map(|m| CachedMember {
360                        name: m.name.clone(),
361                        kind: match m.kind {
362                            MemberKind::EnumMember => "enum".to_string(),
363                            MemberKind::ClassMethod => "method".to_string(),
364                            MemberKind::ClassProperty => "property".to_string(),
365                        },
366                        span_start: m.span.start,
367                        span_end: m.span.end,
368                    })
369                    .collect(),
370            })
371            .collect(),
372        imports: module
373            .imports
374            .iter()
375            .map(|i| {
376                let (kind, imported_name) = match &i.imported_name {
377                    crate::extract::ImportedName::Named(n) => (IMPORT_KIND_NAMED, n.clone()),
378                    crate::extract::ImportedName::Default => (IMPORT_KIND_DEFAULT, String::new()),
379                    crate::extract::ImportedName::Namespace => {
380                        (IMPORT_KIND_NAMESPACE, String::new())
381                    }
382                    crate::extract::ImportedName::SideEffect => {
383                        (IMPORT_KIND_SIDE_EFFECT, String::new())
384                    }
385                };
386                CachedImport {
387                    source: i.source.clone(),
388                    imported_name,
389                    local_name: i.local_name.clone(),
390                    is_type_only: i.is_type_only,
391                    kind,
392                    span_start: i.span.start,
393                    span_end: i.span.end,
394                }
395            })
396            .collect(),
397        re_exports: module
398            .re_exports
399            .iter()
400            .map(|r| CachedReExport {
401                source: r.source.clone(),
402                imported_name: r.imported_name.clone(),
403                exported_name: r.exported_name.clone(),
404                is_type_only: r.is_type_only,
405            })
406            .collect(),
407        dynamic_imports: module
408            .dynamic_imports
409            .iter()
410            .map(|d| CachedDynamicImport {
411                source: d.source.clone(),
412                span_start: d.span.start,
413                span_end: d.span.end,
414                destructured_names: d.destructured_names.clone(),
415                local_name: d.local_name.clone(),
416            })
417            .collect(),
418        require_calls: module
419            .require_calls
420            .iter()
421            .map(|r| CachedRequireCall {
422                source: r.source.clone(),
423                span_start: r.span.start,
424                span_end: r.span.end,
425                destructured_names: r.destructured_names.clone(),
426                local_name: r.local_name.clone(),
427            })
428            .collect(),
429        member_accesses: module.member_accesses.clone(),
430        whole_object_uses: module.whole_object_uses.clone(),
431        dynamic_import_patterns: module
432            .dynamic_import_patterns
433            .iter()
434            .map(|p| CachedDynamicImportPattern {
435                prefix: p.prefix.clone(),
436                suffix: p.suffix.clone(),
437                span_start: p.span.start,
438                span_end: p.span.end,
439            })
440            .collect(),
441        has_cjs_exports: module.has_cjs_exports,
442        suppressions: module
443            .suppressions
444            .iter()
445            .map(|s| CachedSuppression {
446                line: s.line,
447                kind: s.kind.map_or(0, |k| k.to_discriminant()),
448            })
449            .collect(),
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::discover::FileId;
457    use crate::extract::*;
458
459    #[test]
460    fn cache_store_new_is_empty() {
461        let store = CacheStore::new();
462        assert!(store.is_empty());
463        assert_eq!(store.len(), 0);
464    }
465
466    #[test]
467    fn cache_store_default_is_empty() {
468        let store = CacheStore::default();
469        assert!(store.is_empty());
470    }
471
472    #[test]
473    fn cache_store_insert_and_get() {
474        let mut store = CacheStore::new();
475        let module = CachedModule {
476            content_hash: 42,
477            exports: vec![],
478            imports: vec![],
479            re_exports: vec![],
480            dynamic_imports: vec![],
481            require_calls: vec![],
482            member_accesses: vec![],
483            whole_object_uses: vec![],
484            dynamic_import_patterns: vec![],
485            has_cjs_exports: false,
486            suppressions: vec![],
487        };
488        store.insert(Path::new("test.ts"), module);
489        assert_eq!(store.len(), 1);
490        assert!(!store.is_empty());
491        assert!(store.get(Path::new("test.ts"), 42).is_some());
492    }
493
494    #[test]
495    fn cache_store_hash_mismatch_returns_none() {
496        let mut store = CacheStore::new();
497        let module = CachedModule {
498            content_hash: 42,
499            exports: vec![],
500            imports: vec![],
501            re_exports: vec![],
502            dynamic_imports: vec![],
503            require_calls: vec![],
504            member_accesses: vec![],
505            whole_object_uses: vec![],
506            dynamic_import_patterns: vec![],
507            has_cjs_exports: false,
508            suppressions: vec![],
509        };
510        store.insert(Path::new("test.ts"), module);
511        assert!(store.get(Path::new("test.ts"), 99).is_none());
512    }
513
514    #[test]
515    fn cache_store_missing_key_returns_none() {
516        let store = CacheStore::new();
517        assert!(store.get(Path::new("nonexistent.ts"), 42).is_none());
518    }
519
520    #[test]
521    fn cache_store_overwrite_entry() {
522        let mut store = CacheStore::new();
523        let m1 = CachedModule {
524            content_hash: 1,
525            exports: vec![],
526            imports: vec![],
527            re_exports: vec![],
528            dynamic_imports: vec![],
529            require_calls: vec![],
530            member_accesses: vec![],
531            whole_object_uses: vec![],
532            dynamic_import_patterns: vec![],
533            has_cjs_exports: false,
534            suppressions: vec![],
535        };
536        let m2 = CachedModule {
537            content_hash: 2,
538            exports: vec![],
539            imports: vec![],
540            re_exports: vec![],
541            dynamic_imports: vec![],
542            require_calls: vec![],
543            member_accesses: vec![],
544            whole_object_uses: vec![],
545            dynamic_import_patterns: vec![],
546            has_cjs_exports: false,
547            suppressions: vec![],
548        };
549        store.insert(Path::new("test.ts"), m1);
550        store.insert(Path::new("test.ts"), m2);
551        assert_eq!(store.len(), 1);
552        assert!(store.get(Path::new("test.ts"), 1).is_none());
553        assert!(store.get(Path::new("test.ts"), 2).is_some());
554    }
555
556    #[test]
557    fn module_to_cached_roundtrip_named_export() {
558        let module = ModuleInfo {
559            file_id: FileId(0),
560            exports: vec![ExportInfo {
561                name: ExportName::Named("foo".to_string()),
562                local_name: Some("foo".to_string()),
563                is_type_only: false,
564                span: Span::new(10, 20),
565                members: vec![],
566            }],
567            imports: vec![],
568            re_exports: vec![],
569            dynamic_imports: vec![],
570            require_calls: vec![],
571            member_accesses: vec![],
572            whole_object_uses: vec![],
573            dynamic_import_patterns: vec![],
574            has_cjs_exports: false,
575            content_hash: 123,
576            suppressions: vec![],
577        };
578
579        let cached = module_to_cached(&module);
580        let restored = cached_to_module(&cached, FileId(0));
581
582        assert_eq!(restored.exports.len(), 1);
583        assert_eq!(
584            restored.exports[0].name,
585            ExportName::Named("foo".to_string())
586        );
587        assert!(!restored.exports[0].is_type_only);
588        assert_eq!(restored.exports[0].span.start, 10);
589        assert_eq!(restored.exports[0].span.end, 20);
590        assert_eq!(restored.content_hash, 123);
591    }
592
593    #[test]
594    fn module_to_cached_roundtrip_default_export() {
595        let module = ModuleInfo {
596            file_id: FileId(0),
597            exports: vec![ExportInfo {
598                name: ExportName::Default,
599                local_name: None,
600                is_type_only: false,
601                span: Span::new(0, 10),
602                members: vec![],
603            }],
604            imports: vec![],
605            re_exports: vec![],
606            dynamic_imports: vec![],
607            require_calls: vec![],
608            member_accesses: vec![],
609            whole_object_uses: vec![],
610            dynamic_import_patterns: vec![],
611            has_cjs_exports: false,
612            content_hash: 456,
613            suppressions: vec![],
614        };
615
616        let cached = module_to_cached(&module);
617        let restored = cached_to_module(&cached, FileId(0));
618
619        assert_eq!(restored.exports[0].name, ExportName::Default);
620    }
621
622    #[test]
623    fn module_to_cached_roundtrip_imports() {
624        let module = ModuleInfo {
625            file_id: FileId(0),
626            exports: vec![],
627            imports: vec![
628                ImportInfo {
629                    source: "./utils".to_string(),
630                    imported_name: ImportedName::Named("foo".to_string()),
631                    local_name: "foo".to_string(),
632                    is_type_only: false,
633                    span: Span::new(0, 10),
634                },
635                ImportInfo {
636                    source: "react".to_string(),
637                    imported_name: ImportedName::Default,
638                    local_name: "React".to_string(),
639                    is_type_only: false,
640                    span: Span::new(15, 30),
641                },
642                ImportInfo {
643                    source: "./all".to_string(),
644                    imported_name: ImportedName::Namespace,
645                    local_name: "all".to_string(),
646                    is_type_only: false,
647                    span: Span::new(35, 50),
648                },
649                ImportInfo {
650                    source: "./styles.css".to_string(),
651                    imported_name: ImportedName::SideEffect,
652                    local_name: String::new(),
653                    is_type_only: false,
654                    span: Span::new(55, 70),
655                },
656            ],
657            re_exports: vec![],
658            dynamic_imports: vec![],
659            require_calls: vec![],
660            member_accesses: vec![],
661            whole_object_uses: vec![],
662            dynamic_import_patterns: vec![],
663            has_cjs_exports: false,
664            content_hash: 789,
665            suppressions: vec![],
666        };
667
668        let cached = module_to_cached(&module);
669        let restored = cached_to_module(&cached, FileId(0));
670
671        assert_eq!(restored.imports.len(), 4);
672        assert_eq!(
673            restored.imports[0].imported_name,
674            ImportedName::Named("foo".to_string())
675        );
676        assert_eq!(restored.imports[0].span.start, 0);
677        assert_eq!(restored.imports[0].span.end, 10);
678        assert_eq!(restored.imports[1].imported_name, ImportedName::Default);
679        assert_eq!(restored.imports[1].span.start, 15);
680        assert_eq!(restored.imports[1].span.end, 30);
681        assert_eq!(restored.imports[2].imported_name, ImportedName::Namespace);
682        assert_eq!(restored.imports[2].span.start, 35);
683        assert_eq!(restored.imports[2].span.end, 50);
684        assert_eq!(restored.imports[3].imported_name, ImportedName::SideEffect);
685        assert_eq!(restored.imports[3].span.start, 55);
686        assert_eq!(restored.imports[3].span.end, 70);
687    }
688
689    #[test]
690    fn module_to_cached_roundtrip_re_exports() {
691        let module = ModuleInfo {
692            file_id: FileId(0),
693            exports: vec![],
694            imports: vec![],
695            re_exports: vec![ReExportInfo {
696                source: "./module".to_string(),
697                imported_name: "foo".to_string(),
698                exported_name: "bar".to_string(),
699                is_type_only: true,
700            }],
701            dynamic_imports: vec![],
702            require_calls: vec![],
703            member_accesses: vec![],
704            whole_object_uses: vec![],
705            dynamic_import_patterns: vec![],
706            has_cjs_exports: false,
707            content_hash: 0,
708            suppressions: vec![],
709        };
710
711        let cached = module_to_cached(&module);
712        let restored = cached_to_module(&cached, FileId(0));
713
714        assert_eq!(restored.re_exports.len(), 1);
715        assert_eq!(restored.re_exports[0].source, "./module");
716        assert_eq!(restored.re_exports[0].imported_name, "foo");
717        assert_eq!(restored.re_exports[0].exported_name, "bar");
718        assert!(restored.re_exports[0].is_type_only);
719    }
720
721    #[test]
722    fn module_to_cached_roundtrip_dynamic_imports() {
723        let module = ModuleInfo {
724            file_id: FileId(0),
725            exports: vec![],
726            imports: vec![],
727            re_exports: vec![],
728            dynamic_imports: vec![DynamicImportInfo {
729                source: "./lazy".to_string(),
730                span: Span::new(0, 10),
731                destructured_names: Vec::new(),
732                local_name: None,
733            }],
734            require_calls: vec![RequireCallInfo {
735                source: "fs".to_string(),
736                span: Span::new(15, 25),
737                destructured_names: Vec::new(),
738                local_name: None,
739            }],
740            member_accesses: vec![MemberAccess {
741                object: "Status".to_string(),
742                member: "Active".to_string(),
743            }],
744            whole_object_uses: vec![],
745            dynamic_import_patterns: vec![],
746            has_cjs_exports: true,
747            content_hash: 0,
748            suppressions: vec![],
749        };
750
751        let cached = module_to_cached(&module);
752        let restored = cached_to_module(&cached, FileId(0));
753
754        assert_eq!(restored.dynamic_imports.len(), 1);
755        assert_eq!(restored.dynamic_imports[0].source, "./lazy");
756        assert_eq!(restored.dynamic_imports[0].span.start, 0);
757        assert_eq!(restored.dynamic_imports[0].span.end, 10);
758        assert_eq!(restored.require_calls.len(), 1);
759        assert_eq!(restored.require_calls[0].source, "fs");
760        assert_eq!(restored.require_calls[0].span.start, 15);
761        assert_eq!(restored.require_calls[0].span.end, 25);
762        assert_eq!(restored.member_accesses.len(), 1);
763        assert_eq!(restored.member_accesses[0].object, "Status");
764        assert_eq!(restored.member_accesses[0].member, "Active");
765        assert!(restored.has_cjs_exports);
766    }
767
768    #[test]
769    fn module_to_cached_roundtrip_members() {
770        let module = ModuleInfo {
771            file_id: FileId(0),
772            exports: vec![ExportInfo {
773                name: ExportName::Named("Color".to_string()),
774                local_name: Some("Color".to_string()),
775                is_type_only: false,
776                span: Span::new(0, 50),
777                members: vec![
778                    MemberInfo {
779                        name: "Red".to_string(),
780                        kind: MemberKind::EnumMember,
781                        span: Span::new(10, 15),
782                    },
783                    MemberInfo {
784                        name: "greet".to_string(),
785                        kind: MemberKind::ClassMethod,
786                        span: Span::new(20, 30),
787                    },
788                    MemberInfo {
789                        name: "name".to_string(),
790                        kind: MemberKind::ClassProperty,
791                        span: Span::new(35, 45),
792                    },
793                ],
794            }],
795            imports: vec![],
796            re_exports: vec![],
797            dynamic_imports: vec![],
798            require_calls: vec![],
799            member_accesses: vec![],
800            whole_object_uses: vec![],
801            dynamic_import_patterns: vec![],
802            has_cjs_exports: false,
803            content_hash: 0,
804            suppressions: vec![],
805        };
806
807        let cached = module_to_cached(&module);
808        let restored = cached_to_module(&cached, FileId(0));
809
810        assert_eq!(restored.exports[0].members.len(), 3);
811        assert_eq!(restored.exports[0].members[0].kind, MemberKind::EnumMember);
812        assert_eq!(restored.exports[0].members[1].kind, MemberKind::ClassMethod);
813        assert_eq!(
814            restored.exports[0].members[2].kind,
815            MemberKind::ClassProperty
816        );
817    }
818
819    #[test]
820    fn cache_load_nonexistent_returns_none() {
821        let result = CacheStore::load(Path::new("/nonexistent/path"));
822        assert!(result.is_none());
823    }
824
825    /// Create a unique temporary directory for cache tests.
826    fn test_cache_dir(name: &str) -> std::path::PathBuf {
827        let dir = std::env::temp_dir()
828            .join("fallow_cache_tests")
829            .join(name)
830            .join(format!("{}", std::process::id()));
831        // Clean up any leftover from previous runs
832        let _ = std::fs::remove_dir_all(&dir);
833        std::fs::create_dir_all(&dir).unwrap();
834        dir
835    }
836
837    #[test]
838    fn cache_save_and_load_roundtrip() {
839        let dir = test_cache_dir("roundtrip");
840        let mut store = CacheStore::new();
841        let module = CachedModule {
842            content_hash: 42,
843            exports: vec![],
844            imports: vec![],
845            re_exports: vec![],
846            dynamic_imports: vec![],
847            require_calls: vec![],
848            member_accesses: vec![],
849            whole_object_uses: vec![],
850            dynamic_import_patterns: vec![],
851            has_cjs_exports: false,
852            suppressions: vec![],
853        };
854        store.insert(Path::new("test.ts"), module);
855        store.save(&dir).unwrap();
856
857        let loaded = CacheStore::load(&dir);
858        assert!(loaded.is_some());
859        let loaded = loaded.unwrap();
860        assert_eq!(loaded.len(), 1);
861        assert!(loaded.get(Path::new("test.ts"), 42).is_some());
862
863        let _ = std::fs::remove_dir_all(&dir);
864    }
865
866    #[test]
867    fn cache_version_mismatch_returns_none() {
868        let dir = test_cache_dir("version_mismatch");
869        let mut store = CacheStore::new();
870        let module = CachedModule {
871            content_hash: 42,
872            exports: vec![],
873            imports: vec![],
874            re_exports: vec![],
875            dynamic_imports: vec![],
876            require_calls: vec![],
877            member_accesses: vec![],
878            whole_object_uses: vec![],
879            dynamic_import_patterns: vec![],
880            has_cjs_exports: false,
881            suppressions: vec![],
882        };
883        store.insert(Path::new("test.ts"), module);
884        store.save(&dir).unwrap();
885
886        // Verify the cache loads correctly before tampering
887        assert!(CacheStore::load(&dir).is_some());
888
889        // Read raw bytes and modify the version field.
890        // With bincode standard config, u32 is varint-encoded.
891        // The version (CACHE_VERSION) is the first encoded field.
892        // Replace the first byte with a different version value (e.g., 255)
893        // to simulate a version mismatch.
894        let cache_file = dir.join("cache.bin");
895        let mut data = std::fs::read(&cache_file).unwrap();
896        assert!(!data.is_empty());
897        data[0] = 255; // Corrupt the version byte
898        std::fs::write(&cache_file, &data).unwrap();
899
900        // Loading should return None due to version mismatch
901        let result = CacheStore::load(&dir);
902        assert!(result.is_none());
903
904        let _ = std::fs::remove_dir_all(&dir);
905    }
906
907    #[test]
908    fn module_to_cached_roundtrip_type_only_import() {
909        let module = ModuleInfo {
910            file_id: FileId(0),
911            exports: vec![],
912            imports: vec![ImportInfo {
913                source: "./types".to_string(),
914                imported_name: ImportedName::Named("Foo".to_string()),
915                local_name: "Foo".to_string(),
916                is_type_only: true,
917                span: Span::new(0, 10),
918            }],
919            re_exports: vec![],
920            dynamic_imports: vec![],
921            require_calls: vec![],
922            member_accesses: vec![],
923            whole_object_uses: vec![],
924            dynamic_import_patterns: vec![],
925            has_cjs_exports: false,
926            content_hash: 0,
927            suppressions: vec![],
928        };
929
930        let cached = module_to_cached(&module);
931        let restored = cached_to_module(&cached, FileId(0));
932
933        assert!(restored.imports[0].is_type_only);
934        assert_eq!(restored.imports[0].span.start, 0);
935        assert_eq!(restored.imports[0].span.end, 10);
936    }
937}