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