Skip to main content

lisette_semantics/cache/
mod.rs

1pub mod go_stdlib;
2pub mod prelude;
3pub mod types;
4
5use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
6use std::fs;
7use std::hash::{Hash, Hasher};
8use std::io;
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11
12use serde::{Deserialize, Serialize};
13use syntax::program::File;
14
15use crate::store::{ENTRY_MODULE_ID, Store};
16use types::CachedDefinition;
17
18/// Current cache format version. Bump this when making breaking changes to the cache format.
19pub const CACHE_FORMAT_VERSION: u32 = 1;
20
21/// Compiler version hash. Caches from different compiler versions are invalid.
22pub const COMPILER_VERSION_HASH: u64 = const_fnv1a_hash(env!("CARGO_PKG_VERSION").as_bytes());
23
24/// Combined stdlib content hash. Changes to any stdlib file (prelude.d.lis
25/// or any typedefs/*.d.lis) will change this hash, invalidating all user module caches.
26pub const STDLIB_HASH: u64 = stdlib::STDLIB_CONTENT_HASH;
27
28/// Prelude-only content hash (prelude.d.lis).
29pub const PRELUDE_HASH: u64 = stdlib::PRELUDE_CONTENT_HASH;
30
31/// Go stdlib-only content hash (typedefs/*.d.lis).
32pub const GO_STDLIB_HASH: u64 = stdlib::GO_STD_CONTENT_HASH;
33
34const FNV_OFFSET: u64 = 0xcbf29ce484222325;
35const FNV_PRIME: u64 = 0x100000001b3;
36
37/// Compile-time FNV-1a hash function for creating version hashes.
38const fn const_fnv1a_hash(bytes: &[u8]) -> u64 {
39    let mut hash = FNV_OFFSET;
40    let mut i = 0;
41    while i < bytes.len() {
42        hash ^= bytes[i] as u64;
43        hash = hash.wrapping_mul(FNV_PRIME);
44        i += 1;
45    }
46    hash
47}
48
49/// FNV-1a hasher implementing `std::hash::Hasher`.
50/// Unlike `DefaultHasher`, this produces deterministic hashes across Rust versions.
51struct FnvHasher(u64);
52
53impl FnvHasher {
54    fn new() -> Self {
55        Self(FNV_OFFSET)
56    }
57}
58
59impl Hasher for FnvHasher {
60    fn write(&mut self, bytes: &[u8]) {
61        for &byte in bytes {
62            self.0 ^= byte as u64;
63            self.0 = self.0.wrapping_mul(FNV_PRIME);
64        }
65    }
66
67    fn finish(&self) -> u64 {
68        self.0
69    }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ModuleInterface {
74    pub version: u32,
75
76    pub compiler_version: u64,
77
78    pub stdlib_hash: u64,
79
80    /// This module's content hash: hash(source_hash + dependency module_hashes)
81    /// Used by downstream modules to detect transitive changes
82    pub module_hash: u64,
83
84    pub source_hash: u64,
85
86    /// Module hash of each direct dependency.
87    pub dependency_hashes: HashMap<String, u64>,
88
89    pub files: Vec<CachedFile>,
90
91    pub definitions: HashMap<String, CachedDefinition>,
92
93    /// UFCS method pairs for this module, computed during registration.
94    pub ufcs_methods: Vec<(String, String)>,
95
96    /// Artifact hash of the on-disk Go files produced for this module.
97    /// `None` after a Check-phase save or before the post-write stamp call;
98    /// `Some(h)` when the on-disk Go files came from a successful Emit for
99    /// artifact hash `h`.
100    pub emit_stamp: Option<u64>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct CachedFile {
105    pub name: String,
106    pub source: String,
107}
108
109#[derive(Debug, Clone)]
110pub struct CompiledModule {
111    pub module_id: String,
112    pub source_hash: u64,
113    pub dep_hashes: HashMap<String, u64>,
114}
115
116#[derive(Debug, Clone)]
117pub struct EmitStamp {
118    pub module_id: String,
119    pub artifact_hash: u64,
120}
121
122/// Hash over the non-debug Go-artifact inputs for one module.
123pub fn compute_emit_artifact_hash(source_hash: u64, go_module: &str) -> u64 {
124    let mut hasher = FnvHasher::new();
125    source_hash.hash(&mut hasher);
126    go_module.hash(&mut hasher);
127    hasher.finish()
128}
129
130pub fn hash_module_sources(files: &[File]) -> u64 {
131    let mut hasher = FnvHasher::new();
132
133    let mut sorted: Vec<_> = files.iter().collect();
134    sorted.sort_by_key(|f| &f.name);
135
136    for file in sorted {
137        file.name.hash(&mut hasher);
138        file.source.hash(&mut hasher);
139    }
140
141    hasher.finish()
142}
143
144/// Compute a module's hash from its source hash and dependency hashes.
145/// This ensures transitive invalidation: if C changes, B's module_hash changes
146/// (even though B's source didn't), which invalidates A's cache.
147pub fn compute_module_hash(source_hash: u64, dep_hashes: &HashMap<String, u64>) -> u64 {
148    let mut hasher = FnvHasher::new();
149    source_hash.hash(&mut hasher);
150
151    let mut deps: Vec<_> = dep_hashes.iter().collect();
152    deps.sort_by_key(|(k, _)| *k);
153    for (name, hash) in deps {
154        name.hash(&mut hasher);
155        hash.hash(&mut hasher);
156    }
157
158    hasher.finish()
159}
160
161pub fn get_dependency_module_hashes(
162    module_id: &str,
163    edges: &HashMap<String, HashSet<String>>,
164    module_hashes: &HashMap<String, u64>,
165) -> HashMap<String, u64> {
166    let Some(deps) = edges.get(module_id) else {
167        return HashMap::default();
168    };
169
170    deps.iter()
171        .map(|dep_id| {
172            let hash = if dep_id.starts_with("go:") || dep_id == "prelude" {
173                STDLIB_HASH
174            } else {
175                *module_hashes.get(dep_id).unwrap_or(&0)
176            };
177            (dep_id.clone(), hash)
178        })
179        .collect()
180}
181
182pub fn is_cache_valid(
183    cache: &ModuleInterface,
184    current_source_hash: u64,
185    current_dep_hashes: &HashMap<String, u64>,
186) -> bool {
187    cache.version == CACHE_FORMAT_VERSION
188        && cache.compiler_version == COMPILER_VERSION_HASH
189        && cache.stdlib_hash == STDLIB_HASH
190        && cache.source_hash == current_source_hash
191        && cache.dependency_hashes == *current_dep_hashes
192}
193
194pub fn cache_path(project_root: &Path, module_id: &str) -> PathBuf {
195    project_root
196        .join("target")
197        .join("cache")
198        .join(cache_file_name(module_id))
199}
200
201pub fn cache_file_name(module_id: &str) -> String {
202    format!("{}.cache", module_id.replace('/', "_"))
203}
204
205pub fn try_load_cache(
206    module_id: &str,
207    expected_source_hash: u64,
208    expected_dep_hashes: &HashMap<String, u64>,
209    expected_artifact_hash: Option<u64>,
210    project_root: &Path,
211    check_go_files: bool,
212) -> Option<ModuleInterface> {
213    let path = cache_path(project_root, module_id);
214    let bytes = fs::read(&path).ok()?;
215    let interface: ModuleInterface = match bincode::deserialize(&bytes) {
216        Ok(i) => i,
217        Err(_) => {
218            let _ = fs::remove_file(&path);
219            return None;
220        }
221    };
222
223    if !is_cache_valid(&interface, expected_source_hash, expected_dep_hashes) {
224        let _ = fs::remove_file(&path);
225        return None;
226    }
227
228    if check_go_files {
229        if interface.emit_stamp != expected_artifact_hash {
230            return None;
231        }
232        if !all_go_outputs_exist(module_id, &interface.files, project_root) {
233            return None;
234        }
235    }
236
237    Some(interface)
238}
239
240fn all_go_outputs_exist(module_id: &str, cached_files: &[CachedFile], project_root: &Path) -> bool {
241    let target_dir = if module_id == ENTRY_MODULE_ID {
242        project_root.join("target")
243    } else {
244        project_root.join("target").join(module_id)
245    };
246
247    for cached_file in cached_files {
248        if cached_file.name.ends_with(".lis") && !cached_file.name.ends_with(".d.lis") {
249            let go_name = cached_file.name.replace(".lis", ".go");
250            if !target_dir.join(&go_name).exists() {
251                return false;
252            }
253        }
254    }
255
256    true
257}
258
259pub fn save_module_cache(
260    compiled: &CompiledModule,
261    store: &Store,
262    project_root: &Path,
263    ufcs_methods: &HashSet<(String, String)>,
264) -> io::Result<()> {
265    let module_hash = compute_module_hash(compiled.source_hash, &compiled.dep_hashes);
266
267    let Some(module) = store.get_module(&compiled.module_id) else {
268        return Err(io::Error::other("module not found in store"));
269    };
270
271    let mut all_files: Vec<_> = module
272        .files
273        .values()
274        .chain(module.typedefs.values())
275        .collect();
276    all_files.sort_by_key(|f| &f.name);
277
278    let file_id_to_index: HashMap<u32, u32> = all_files
279        .iter()
280        .enumerate()
281        .map(|(idx, f)| (f.id, idx as u32))
282        .collect();
283
284    let interface = ModuleInterface {
285        version: CACHE_FORMAT_VERSION,
286        compiler_version: COMPILER_VERSION_HASH,
287        stdlib_hash: STDLIB_HASH,
288        module_hash,
289        source_hash: compiled.source_hash,
290        dependency_hashes: compiled.dep_hashes.clone(),
291        files: all_files
292            .iter()
293            .map(|f| CachedFile {
294                name: f.name.clone(),
295                source: f.source.clone(),
296            })
297            .collect(),
298        definitions: extract_public_definitions(store, &compiled.module_id, &file_id_to_index),
299        ufcs_methods: {
300            let prefix = format!("{}.", compiled.module_id);
301            ufcs_methods
302                .iter()
303                .filter(|(type_id, _)| type_id.starts_with(&prefix))
304                .cloned()
305                .collect()
306        },
307        emit_stamp: None,
308    };
309
310    let path = cache_path(project_root, &compiled.module_id);
311    if let Some(parent) = path.parent() {
312        fs::create_dir_all(parent)?;
313    }
314
315    // Write to temp file, then rename (atomic)
316    let temp_path = path.with_extension("cache.tmp");
317    let bytes = bincode::serialize(&interface).map_err(io::Error::other)?;
318    fs::write(&temp_path, bytes)?;
319    fs::rename(&temp_path, &path)?;
320
321    Ok(())
322}
323
324fn extract_public_definitions(
325    store: &Store,
326    module_id: &str,
327    file_id_to_index: &HashMap<u32, u32>,
328) -> HashMap<String, CachedDefinition> {
329    let Some(module) = store.get_module(module_id) else {
330        return HashMap::default();
331    };
332
333    module
334        .definitions
335        .iter()
336        .filter(|(_, definition)| definition.visibility().is_public())
337        .map(|(name, definition)| {
338            (
339                name.to_string(),
340                CachedDefinition::from_definition(definition, file_id_to_index),
341            )
342        })
343        .collect()
344}
345
346/// Register a cached module in the store.
347/// `display_path` for each cached file is recomputed from the project layout
348/// against the current cwd, so warm and cold builds render the same diagnostic
349/// paths even though the cache stores only bare names.
350pub fn register_cached_module(
351    store: &mut Store,
352    module_id: &str,
353    cached: ModuleInterface,
354    project_root: &Path,
355) {
356    store.add_module(module_id);
357
358    let mut file_ids: Vec<u32> = vec![];
359    for cached_file in &cached.files {
360        let file_id = store.new_file_id();
361        file_ids.push(file_id);
362
363        let display_path = cached_file_display_path(project_root, module_id, &cached_file.name);
364        let file = File::new_cached(
365            module_id,
366            &cached_file.name,
367            &display_path,
368            &cached_file.source,
369            file_id,
370        );
371
372        store.store_file(module_id, file);
373    }
374
375    let module = store.get_module_mut(module_id).unwrap();
376    for (qualified_name, cached_definition) in cached.definitions {
377        let definition = cached_definition.to_definition(&file_ids);
378        module.definitions.insert(qualified_name.into(), definition);
379    }
380
381    store.mark_visited(module_id);
382}
383
384fn cached_file_display_path(project_root: &Path, module_id: &str, bare_name: &str) -> String {
385    let on_disk = if module_id == ENTRY_MODULE_ID {
386        project_root.join("src").join(bare_name)
387    } else {
388        project_root.join("src").join(module_id).join(bare_name)
389    };
390    crate::path::relative_to_cwd(&on_disk).unwrap_or_else(|| bare_name.to_string())
391}
392
393/// Set or clear the `emit_stamp` for each module's cache file. Missing files
394/// are skipped; undecodable (e.g. pre-bump) files are unlinked and skipped;
395/// other read errors propagate so the debug pre-write clear can hard-fail
396/// rather than leave a stale stamp over freshly-overwritten Go.
397pub fn apply_emit_stamps(
398    project_root: &Path,
399    updates: &[(EmitStamp, Option<u64>)],
400) -> io::Result<()> {
401    for (stamp, value) in updates {
402        let path = cache_path(project_root, &stamp.module_id);
403        let bytes = match fs::read(&path) {
404            Ok(b) => b,
405            Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
406            Err(e) => return Err(e),
407        };
408        let mut interface: ModuleInterface = match bincode::deserialize(&bytes) {
409            Ok(i) => i,
410            Err(_) => {
411                let _ = fs::remove_file(&path);
412                continue;
413            }
414        };
415        interface.emit_stamp = *value;
416
417        let temp_path = path.with_extension("cache.tmp");
418        let new_bytes = bincode::serialize(&interface).map_err(io::Error::other)?;
419        fs::write(&temp_path, new_bytes)?;
420        fs::rename(&temp_path, &path)?;
421    }
422    Ok(())
423}
424
425pub fn is_cache_disabled() -> bool {
426    std::env::var("LISETTE_NO_CACHE")
427        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
428        .unwrap_or(false)
429}
430
431static GLOBAL_CACHE_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
432
433pub(crate) fn global_cache_temp_path(final_path: &Path) -> PathBuf {
434    let counter = GLOBAL_CACHE_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
435    final_path.with_extension(format!("tmp.{}.{}", std::process::id(), counter))
436}
437
438pub(crate) fn prune_legacy_global_caches(dir: &Path, prefix: &str) {
439    let Ok(entries) = fs::read_dir(dir) else {
440        return;
441    };
442    for entry in entries.flatten() {
443        let name = entry.file_name();
444        let name = name.to_string_lossy();
445        if name.starts_with(prefix) && name.contains("_compiler_") {
446            let _ = fs::remove_file(entry.path());
447        }
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use syntax::types::{Symbol, Type};
455
456    #[test]
457    fn test_hash_module_sources_deterministic() {
458        let file1 = File::new_cached("mod", "a.lis", "a.lis", "fn foo() {}", 1);
459        let file2 = File::new_cached("mod", "b.lis", "b.lis", "fn bar() {}", 2);
460
461        let hash1 = hash_module_sources(&[file1.clone(), file2.clone()]);
462        let hash2 = hash_module_sources(&[file2.clone(), file1.clone()]);
463
464        assert_eq!(hash1, hash2);
465    }
466
467    #[test]
468    fn test_hash_module_sources_content_sensitive() {
469        let file1 = File::new_cached("mod", "a.lis", "a.lis", "fn foo() {}", 1);
470        let file2 = File::new_cached("mod", "a.lis", "a.lis", "fn bar() {}", 1);
471
472        let hash1 = hash_module_sources(&[file1]);
473        let hash2 = hash_module_sources(&[file2]);
474
475        assert_ne!(hash1, hash2);
476    }
477
478    #[test]
479    fn test_compute_module_hash_includes_deps() {
480        let source_hash = 12345u64;
481        let mut deps1 = HashMap::default();
482        deps1.insert("dep_a".to_string(), 111u64);
483
484        let mut deps2 = HashMap::default();
485        deps2.insert("dep_a".to_string(), 222u64);
486
487        let hash1 = compute_module_hash(source_hash, &deps1);
488        let hash2 = compute_module_hash(source_hash, &deps2);
489
490        assert_ne!(hash1, hash2);
491    }
492
493    #[test]
494    fn test_compute_module_hash_deterministic() {
495        let source_hash = 12345u64;
496        let mut deps = HashMap::default();
497        deps.insert("dep_b".to_string(), 222u64);
498        deps.insert("dep_a".to_string(), 111u64);
499
500        let hash1 = compute_module_hash(source_hash, &deps);
501        let hash2 = compute_module_hash(source_hash, &deps);
502
503        assert_eq!(hash1, hash2);
504    }
505
506    #[test]
507    fn test_cache_validity_checks_version() {
508        let cache = ModuleInterface {
509            version: CACHE_FORMAT_VERSION + 1, // Wrong version
510            compiler_version: COMPILER_VERSION_HASH,
511            stdlib_hash: STDLIB_HASH,
512            module_hash: 0,
513            source_hash: 100,
514            dependency_hashes: HashMap::default(),
515            files: vec![],
516            definitions: HashMap::default(),
517            ufcs_methods: vec![],
518            emit_stamp: None,
519        };
520
521        assert!(!is_cache_valid(&cache, 100, &HashMap::default()));
522    }
523
524    #[test]
525    fn test_cache_validity_checks_compiler_version() {
526        let cache = ModuleInterface {
527            version: CACHE_FORMAT_VERSION,
528            compiler_version: COMPILER_VERSION_HASH + 1, // Wrong compiler
529            stdlib_hash: STDLIB_HASH,
530            module_hash: 0,
531            source_hash: 100,
532            dependency_hashes: HashMap::default(),
533            files: vec![],
534            definitions: HashMap::default(),
535            ufcs_methods: vec![],
536            emit_stamp: None,
537        };
538
539        assert!(!is_cache_valid(&cache, 100, &HashMap::default()));
540    }
541
542    #[test]
543    fn test_cache_validity_checks_source_hash() {
544        let cache = ModuleInterface {
545            version: CACHE_FORMAT_VERSION,
546            compiler_version: COMPILER_VERSION_HASH,
547            stdlib_hash: STDLIB_HASH,
548            module_hash: 0,
549            source_hash: 100,
550            dependency_hashes: HashMap::default(),
551            files: vec![],
552            definitions: HashMap::default(),
553            ufcs_methods: vec![],
554            emit_stamp: None,
555        };
556
557        assert!(!is_cache_valid(&cache, 200, &HashMap::default()));
558        assert!(is_cache_valid(&cache, 100, &HashMap::default()));
559    }
560
561    #[test]
562    fn test_cache_validity_checks_dep_hashes() {
563        let mut cached_deps = HashMap::default();
564        cached_deps.insert("dep".to_string(), 111u64);
565
566        let cache = ModuleInterface {
567            version: CACHE_FORMAT_VERSION,
568            compiler_version: COMPILER_VERSION_HASH,
569            stdlib_hash: STDLIB_HASH,
570            module_hash: 0,
571            source_hash: 100,
572            dependency_hashes: cached_deps.clone(),
573            files: vec![],
574            definitions: HashMap::default(),
575            ufcs_methods: vec![],
576            emit_stamp: None,
577        };
578
579        let mut different_deps = HashMap::default();
580        different_deps.insert("dep".to_string(), 222u64);
581
582        assert!(!is_cache_valid(&cache, 100, &different_deps));
583        assert!(is_cache_valid(&cache, 100, &cached_deps));
584    }
585
586    #[test]
587    fn test_type_roundtrip_bincode() {
588        let ty = Type::Function {
589            params: vec![Type::Nominal {
590                id: Symbol::from_raw("int"),
591                params: vec![],
592                underlying_ty: None,
593            }],
594            param_mutability: vec![false],
595            bounds: vec![],
596            return_type: Box::new(Type::Nominal {
597                id: Symbol::from_raw("main.MyType"),
598                params: vec![Type::Tuple(vec![Type::Never])],
599                underlying_ty: None,
600            }),
601        };
602
603        let bytes = bincode::serialize(&ty).unwrap();
604        let restored: Type = bincode::deserialize(&bytes).unwrap();
605        assert_eq!(ty, restored);
606    }
607
608    #[test]
609    fn test_cache_path_format() {
610        let path = cache_path(Path::new("/project"), "utils");
611        assert_eq!(path, PathBuf::from("/project/target/cache/utils.cache"));
612
613        let path = cache_path(Path::new("/project"), "deep/nested/mod");
614        assert_eq!(
615            path,
616            PathBuf::from("/project/target/cache/deep_nested_mod.cache")
617        );
618    }
619
620    #[test]
621    fn test_get_dependency_module_hashes_uses_stdlib_hash() {
622        let mut edges = HashMap::default();
623        let mut deps = HashSet::default();
624        deps.insert("go:fmt".to_string());
625        deps.insert("prelude".to_string());
626        deps.insert("user_mod".to_string());
627        edges.insert("my_mod".to_string(), deps);
628
629        let mut module_hashes = HashMap::default();
630        module_hashes.insert("user_mod".to_string(), 12345u64);
631
632        let result = get_dependency_module_hashes("my_mod", &edges, &module_hashes);
633
634        assert_eq!(result.get("go:fmt"), Some(&STDLIB_HASH));
635        assert_eq!(result.get("prelude"), Some(&STDLIB_HASH));
636        assert_eq!(result.get("user_mod"), Some(&12345u64));
637    }
638
639    #[test]
640    fn hash_module_sources_independent_of_display_path() {
641        let cli_file = File::new(
642            "greet",
643            "greet.lis",
644            "src/greet/greet.lis",
645            "pub fn x() -> int { 1 }",
646            vec![],
647            1,
648        );
649        let lsp_file = File::new(
650            "greet",
651            "greet.lis",
652            "greet.lis",
653            "pub fn x() -> int { 1 }",
654            vec![],
655            1,
656        );
657
658        assert_eq!(
659            hash_module_sources(&[cli_file]),
660            hash_module_sources(&[lsp_file]),
661        );
662    }
663
664    #[test]
665    fn cache_file_purity_no_src_prefix() {
666        let cached = CachedFile {
667            name: "greet.lis".to_string(),
668            source: "pub fn x() -> int { 1 }".to_string(),
669        };
670        let bytes = bincode::serialize(&cached).unwrap();
671        let serialized = String::from_utf8_lossy(&bytes);
672        assert!(
673            !serialized.contains("src/"),
674            "CachedFile must not contain `src/` prefix; got: {serialized:?}"
675        );
676    }
677
678    #[test]
679    fn artifact_hash_depends_on_go_module() {
680        let h1 = compute_emit_artifact_hash(100, "github.com/old/proj");
681        let h2 = compute_emit_artifact_hash(100, "github.com/new/proj");
682        assert_ne!(h1, h2);
683    }
684
685    #[test]
686    fn apply_emit_stamps_round_trip() {
687        let tmp = tempfile::tempdir().unwrap();
688        let root = tmp.path();
689        std::fs::create_dir_all(root.join("target").join("cache")).unwrap();
690
691        let interface = ModuleInterface {
692            version: CACHE_FORMAT_VERSION,
693            compiler_version: COMPILER_VERSION_HASH,
694            stdlib_hash: STDLIB_HASH,
695            module_hash: 0,
696            source_hash: 100,
697            dependency_hashes: HashMap::default(),
698            files: vec![],
699            definitions: HashMap::default(),
700            ufcs_methods: vec![],
701            emit_stamp: None,
702        };
703        let path = cache_path(root, "greet");
704        std::fs::write(&path, bincode::serialize(&interface).unwrap()).unwrap();
705
706        let stamp = EmitStamp {
707            module_id: "greet".to_string(),
708            artifact_hash: 999,
709        };
710        apply_emit_stamps(root, &[(stamp.clone(), Some(999))]).unwrap();
711        let reread: ModuleInterface = bincode::deserialize(&std::fs::read(&path).unwrap()).unwrap();
712        assert_eq!(reread.emit_stamp, Some(999));
713        assert_eq!(reread.source_hash, 100);
714
715        apply_emit_stamps(root, &[(stamp, None)]).unwrap();
716        let reread: ModuleInterface = bincode::deserialize(&std::fs::read(&path).unwrap()).unwrap();
717        assert_eq!(reread.emit_stamp, None);
718    }
719
720    #[test]
721    fn apply_emit_stamps_missing_cache_is_no_op() {
722        let tmp = tempfile::tempdir().unwrap();
723        let stamp = EmitStamp {
724            module_id: "absent".to_string(),
725            artifact_hash: 0,
726        };
727        let result = apply_emit_stamps(tmp.path(), &[(stamp, None)]);
728        assert!(result.is_ok());
729    }
730
731    #[test]
732    fn try_load_cache_rejects_unstamped_for_emit() {
733        let tmp = tempfile::tempdir().unwrap();
734        let root = tmp.path();
735        std::fs::create_dir_all(root.join("target").join("cache")).unwrap();
736        std::fs::create_dir_all(root.join("target").join("greet")).unwrap();
737        std::fs::write(root.join("target").join("greet").join("greet.go"), "").unwrap();
738
739        let interface = ModuleInterface {
740            version: CACHE_FORMAT_VERSION,
741            compiler_version: COMPILER_VERSION_HASH,
742            stdlib_hash: STDLIB_HASH,
743            module_hash: 0,
744            source_hash: 100,
745            dependency_hashes: HashMap::default(),
746            files: vec![CachedFile {
747                name: "greet.lis".to_string(),
748                source: String::new(),
749            }],
750            definitions: HashMap::default(),
751            ufcs_methods: vec![],
752            emit_stamp: None,
753        };
754        let path = cache_path(root, "greet");
755        std::fs::write(&path, bincode::serialize(&interface).unwrap()).unwrap();
756
757        let loaded = try_load_cache("greet", 100, &HashMap::default(), None, root, false);
758        assert!(loaded.is_some(), "Check phase must accept unstamped cache");
759
760        let loaded = try_load_cache(
761            "greet",
762            100,
763            &HashMap::default(),
764            Some(compute_emit_artifact_hash(100, "github.com/test/x")),
765            root,
766            true,
767        );
768        assert!(
769            loaded.is_none(),
770            "Emit phase must reject cache with emit_stamp = None"
771        );
772    }
773
774    #[test]
775    fn try_load_cache_rejects_after_debug_invalidation() {
776        let tmp = tempfile::tempdir().unwrap();
777        let root = tmp.path();
778        std::fs::create_dir_all(root.join("target").join("cache")).unwrap();
779        std::fs::create_dir_all(root.join("target").join("greet")).unwrap();
780        std::fs::write(root.join("target").join("greet").join("greet.go"), "").unwrap();
781
782        let artifact_hash = compute_emit_artifact_hash(100, "github.com/test/x");
783
784        let interface = ModuleInterface {
785            version: CACHE_FORMAT_VERSION,
786            compiler_version: COMPILER_VERSION_HASH,
787            stdlib_hash: STDLIB_HASH,
788            module_hash: 0,
789            source_hash: 100,
790            dependency_hashes: HashMap::default(),
791            files: vec![CachedFile {
792                name: "greet.lis".to_string(),
793                source: String::new(),
794            }],
795            definitions: HashMap::default(),
796            ufcs_methods: vec![],
797            emit_stamp: Some(artifact_hash),
798        };
799        let path = cache_path(root, "greet");
800        std::fs::write(&path, bincode::serialize(&interface).unwrap()).unwrap();
801
802        assert!(
803            try_load_cache(
804                "greet",
805                100,
806                &HashMap::default(),
807                Some(artifact_hash),
808                root,
809                true,
810            )
811            .is_some()
812        );
813
814        let stamp = EmitStamp {
815            module_id: "greet".to_string(),
816            artifact_hash,
817        };
818        apply_emit_stamps(root, &[(stamp, None)]).unwrap();
819
820        assert!(
821            try_load_cache(
822                "greet",
823                100,
824                &HashMap::default(),
825                Some(artifact_hash),
826                root,
827                true,
828            )
829            .is_none()
830        );
831    }
832
833    #[test]
834    fn prune_legacy_global_caches_removes_only_hashed_files() {
835        let tmp = tempfile::tempdir().unwrap();
836        let dir = tmp.path();
837        let legacy_prelude = dir.join("prelude_defs_4330e9_compiler_f709f8.bin");
838        let legacy_stdlib = dir.join("stdlib_defs_151b6b_compiler_f709f8_darwin_arm64.bin");
839        let stable_prelude = dir.join("prelude_defs.bin");
840        let stable_stdlib = dir.join("stdlib_defs_darwin_arm64.bin");
841        let other_stdlib = dir.join("stdlib_defs_linux_amd64.bin");
842        for path in [
843            &legacy_prelude,
844            &legacy_stdlib,
845            &stable_prelude,
846            &stable_stdlib,
847            &other_stdlib,
848        ] {
849            std::fs::write(path, b"x").unwrap();
850        }
851
852        prune_legacy_global_caches(dir, "prelude_defs");
853        prune_legacy_global_caches(dir, "stdlib_defs");
854
855        assert!(!legacy_prelude.exists());
856        assert!(!legacy_stdlib.exists());
857        assert!(stable_prelude.exists());
858        assert!(stable_stdlib.exists());
859        assert!(other_stdlib.exists());
860    }
861
862    #[test]
863    fn prune_legacy_global_caches_missing_dir_is_noop() {
864        let tmp = tempfile::tempdir().unwrap();
865        prune_legacy_global_caches(&tmp.path().join("does_not_exist"), "prelude_defs");
866    }
867
868    #[test]
869    fn global_cache_temp_paths_are_unique() {
870        let base = Path::new("/cache/prelude_defs.bin");
871        let first = global_cache_temp_path(base);
872        let second = global_cache_temp_path(base);
873        assert_ne!(first, second);
874    }
875}