Skip to main content

zccache_depgraph/
snapshot.rs

1//! Disk persistence for the dependency graph via rkyv zero-copy serialization.
2//!
3//! Saves/loads the graph to `~/.zccache/depgraph/depgraph.bin` so warm contexts
4//! survive daemon restarts and cache hits resume immediately.
5
6use std::path::Path;
7use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
8
9use dashmap::DashMap;
10use rayon::prelude::*;
11use rkyv::{Archive, Deserialize, Serialize};
12use zccache_core::NormalizedPath;
13use zccache_hash::ContentHash;
14
15use crate::context::{ArtifactKey, CompileContext, ContextKey};
16use crate::graph::{ContextEntry, ContextState, DepGraph, FileEntry};
17use crate::scanner::{IncludeDirective, IncludeKind};
18use crate::search_paths::IncludeSearchPaths;
19
20/// On-disk format version. Bump when snapshot layout changes.
21pub const DEPGRAPH_VERSION: u32 = 4;
22
23/// Magic bytes identifying a depgraph snapshot file ("ZCDG").
24pub const DEPGRAPH_MAGIC: [u8; 4] = [0x5A, 0x43, 0x44, 0x47];
25
26/// Header size: 4 (magic) + 4 (version) + 8 (payload len) = 16 bytes.
27const HEADER_SIZE: usize = 16;
28
29/// Entries older than this are trimmed before persisting the snapshot.
30const GC_TTL: Duration = Duration::from_secs(86_400); // 1 day
31
32/// Initial scratch-space size for rkyv serialization.
33const SERIALIZE_SCRATCH: usize = 4096;
34
35// ---------------------------------------------------------------------------
36// Snapshot types (rkyv-serializable mirrors of the in-memory types)
37// ---------------------------------------------------------------------------
38
39#[derive(Archive, Serialize, Deserialize)]
40#[archive(check_bytes)]
41pub struct DepGraphSnapshot {
42    pub files: Vec<FileEntrySnapshot>,
43    pub contexts: Vec<ContextEntrySnapshot>,
44    pub stats: SnapshotStats,
45}
46
47#[derive(Archive, Serialize, Deserialize)]
48#[archive(check_bytes)]
49pub struct FileEntrySnapshot {
50    pub path: String,
51    pub includes: Vec<IncludeDirectiveSnapshot>,
52}
53
54#[derive(Archive, Serialize, Deserialize)]
55#[archive(check_bytes)]
56pub struct IncludeDirectiveSnapshot {
57    /// 0=Quoted, 1=AngleBracket, 2=Computed
58    pub kind: u8,
59    pub path: String,
60    pub line: u32,
61}
62
63#[derive(Archive, Serialize, Deserialize)]
64#[archive(check_bytes)]
65pub struct ContextEntrySnapshot {
66    pub context_key: [u8; 32],
67    pub key_root: Option<String>,
68    pub source_file: String,
69    pub iquote: Vec<String>,
70    pub user: Vec<String>,
71    pub system: Vec<String>,
72    pub after: Vec<String>,
73    pub defines: Vec<String>,
74    pub flags: Vec<String>,
75    pub force_includes: Vec<String>,
76    pub unknown_flags: Vec<String>,
77    pub resolved_includes: Vec<String>,
78    pub unresolved_includes: Vec<String>,
79    pub has_computed_includes: bool,
80    pub artifact_key: Option<[u8; 32]>,
81    pub last_file_hashes: Vec<(String, [u8; 32])>,
82    /// 0=Cold, 1=Warm, 2=Stale
83    pub state: u8,
84}
85
86#[derive(Archive, Serialize, Deserialize)]
87#[archive(check_bytes)]
88pub struct SnapshotStats {
89    pub saved_at_epoch_secs: u64,
90    pub file_count: u64,
91    pub context_count: u64,
92}
93
94// ---------------------------------------------------------------------------
95// Error type
96// ---------------------------------------------------------------------------
97
98#[derive(Debug, thiserror::Error)]
99pub enum SnapshotError {
100    #[error("io error: {0}")]
101    Io(#[from] std::io::Error),
102
103    #[error("bad magic bytes in depgraph file")]
104    BadMagic,
105
106    #[error("depgraph version mismatch: file has v{file}, expected v{expected}")]
107    VersionMismatch { file: u32, expected: u32 },
108
109    #[error("corrupt depgraph file: {0}")]
110    Corrupt(String),
111}
112
113// ---------------------------------------------------------------------------
114// Conversion: DepGraph -> Snapshot
115// ---------------------------------------------------------------------------
116
117impl DepGraph {
118    /// Create a serializable snapshot of the current graph state.
119    pub fn to_snapshot(&self) -> DepGraphSnapshot {
120        let files: Vec<FileEntrySnapshot> = self
121            .files_iter()
122            .map(|entry| {
123                let path = entry.key().to_string_lossy().into_owned();
124                let includes = entry
125                    .value()
126                    .includes
127                    .iter()
128                    .map(|d| IncludeDirectiveSnapshot {
129                        kind: match &d.kind {
130                            IncludeKind::Quoted => 0,
131                            IncludeKind::AngleBracket => 1,
132                            IncludeKind::Computed(_) => 2,
133                        },
134                        path: d.path.clone(),
135                        line: d.line,
136                    })
137                    .collect();
138                FileEntrySnapshot { path, includes }
139            })
140            .collect();
141
142        let contexts: Vec<ContextEntrySnapshot> = self
143            .contexts_iter()
144            .map(|entry| {
145                let key = entry.key();
146                let ctx = entry.value();
147                ContextEntrySnapshot {
148                    context_key: *key.hash().as_bytes(),
149                    key_root: ctx
150                        .key_root
151                        .as_ref()
152                        .map(|p| p.to_string_lossy().into_owned()),
153                    source_file: ctx.context.source_file.to_string_lossy().into_owned(),
154                    iquote: paths_to_strings(&ctx.context.include_search.iquote),
155                    user: paths_to_strings(&ctx.context.include_search.user),
156                    system: paths_to_strings(&ctx.context.include_search.system),
157                    after: paths_to_strings(&ctx.context.include_search.after),
158                    defines: ctx.context.defines.clone(),
159                    flags: ctx.context.flags.clone(),
160                    force_includes: paths_to_strings(&ctx.context.force_includes),
161                    unknown_flags: ctx.context.unknown_flags.clone(),
162                    resolved_includes: paths_to_strings(&ctx.resolved_includes),
163                    unresolved_includes: ctx.unresolved_includes.clone(),
164                    has_computed_includes: ctx.has_computed_includes,
165                    artifact_key: ctx.artifact_key.map(|k| *k.hash().as_bytes()),
166                    last_file_hashes: ctx
167                        .last_file_hashes
168                        .iter()
169                        .map(|(p, h)| (p.to_string_lossy().into_owned(), *h.as_bytes()))
170                        .collect(),
171                    state: match ctx.state {
172                        ContextState::Cold => 0,
173                        ContextState::Warm => 1,
174                        ContextState::Stale => 2,
175                    },
176                }
177            })
178            .collect();
179
180        DepGraphSnapshot {
181            stats: SnapshotStats {
182                saved_at_epoch_secs: SystemTime::now()
183                    .duration_since(UNIX_EPOCH)
184                    .unwrap_or_default()
185                    .as_secs(),
186                file_count: files.len() as u64,
187                context_count: contexts.len() as u64,
188            },
189            files,
190            contexts,
191        }
192    }
193
194    /// Reconstruct a `DepGraph` from a deserialized snapshot.
195    pub fn from_snapshot(snap: DepGraphSnapshot) -> Self {
196        let files: DashMap<NormalizedPath, FileEntry> = DashMap::new();
197        snap.files.into_par_iter().for_each(|f| {
198            let path = NormalizedPath::from(f.path.as_str());
199            let includes = f
200                .includes
201                .into_iter()
202                .map(|d| {
203                    let kind = match d.kind {
204                        0 => IncludeKind::Quoted,
205                        1 => IncludeKind::AngleBracket,
206                        _ => IncludeKind::Computed(d.path.clone()),
207                    };
208                    IncludeDirective {
209                        kind,
210                        path: d.path,
211                        line: d.line,
212                    }
213                })
214                .collect();
215            files.insert(
216                path,
217                FileEntry {
218                    includes,
219                    scanned_at: Instant::now(),
220                },
221            );
222        });
223
224        let contexts: DashMap<ContextKey, ContextEntry> = DashMap::new();
225        snap.contexts.into_par_iter().for_each(|c| {
226            let key = ContextKey::from_raw(c.context_key);
227            let context = CompileContext {
228                source_file: NormalizedPath::from(c.source_file.as_str()),
229                include_search: IncludeSearchPaths {
230                    iquote: strings_to_paths(c.iquote),
231                    user: strings_to_paths(c.user),
232                    system: strings_to_paths(c.system),
233                    after: strings_to_paths(c.after),
234                },
235                defines: c.defines,
236                flags: c.flags,
237                force_includes: strings_to_paths(c.force_includes),
238                unknown_flags: c.unknown_flags,
239            };
240            let entry = ContextEntry {
241                context,
242                key_root: c.key_root.map(|root| NormalizedPath::from(root.as_str())),
243                resolved_includes: strings_to_paths(c.resolved_includes),
244                unresolved_includes: c.unresolved_includes,
245                has_computed_includes: c.has_computed_includes,
246                artifact_key: c.artifact_key.map(ArtifactKey::from_raw),
247                last_file_hashes: c
248                    .last_file_hashes
249                    .into_iter()
250                    .map(|(p, h)| (NormalizedPath::from(p.as_str()), ContentHash::from_bytes(h)))
251                    .collect(),
252                last_accessed: Instant::now(),
253                state: match c.state {
254                    0 => ContextState::Cold,
255                    1 => ContextState::Warm,
256                    _ => ContextState::Stale,
257                },
258            };
259            contexts.insert(key, entry);
260        });
261
262        DepGraph::from_maps(files, contexts)
263    }
264}
265
266fn paths_to_strings<P: AsRef<Path>>(paths: &[P]) -> Vec<String> {
267    paths
268        .iter()
269        .map(|p| p.as_ref().to_string_lossy().into_owned())
270        .collect()
271}
272
273fn strings_to_paths(strings: Vec<String>) -> Vec<NormalizedPath> {
274    strings.into_iter().map(NormalizedPath::from).collect()
275}
276
277// ---------------------------------------------------------------------------
278// File I/O
279// ---------------------------------------------------------------------------
280
281/// Returns the default path for the depgraph snapshot file.
282#[must_use]
283pub fn depgraph_file_path() -> NormalizedPath {
284    zccache_core::config::depgraph_dir().join("depgraph.bin")
285}
286
287/// Save the dependency graph to disk with atomic write.
288///
289/// GC is applied first (1-day TTL) to avoid persisting stale entries.
290pub fn save_to_file(graph: &DepGraph, path: &Path) -> Result<(), SnapshotError> {
291    // GC: trim stale entries before saving.
292    graph.trim(GC_TTL);
293
294    let snapshot = graph.to_snapshot();
295
296    let payload = rkyv::to_bytes::<_, SERIALIZE_SCRATCH>(&snapshot)
297        .map_err(|e| SnapshotError::Corrupt(format!("serialize: {e}")))?;
298
299    // Build header: magic + version (LE u32) + payload len (LE u64)
300    let mut header = Vec::with_capacity(HEADER_SIZE);
301    header.extend_from_slice(&DEPGRAPH_MAGIC);
302    header.extend_from_slice(&DEPGRAPH_VERSION.to_le_bytes());
303    header.extend_from_slice(&(payload.len() as u64).to_le_bytes());
304
305    // Atomic write: write to .tmp, then rename.
306    let tmp_path = path.with_extension("bin.tmp");
307    if let Some(parent) = path.parent() {
308        std::fs::create_dir_all(parent)?;
309    }
310
311    {
312        let mut file = std::fs::File::create(&tmp_path)?;
313        use std::io::Write;
314        file.write_all(&header)?;
315        file.write_all(&payload)?;
316        file.flush()?;
317    }
318
319    // Windows: remove target before rename (rename doesn't overwrite on Windows).
320    let _ = std::fs::remove_file(path);
321    std::fs::rename(&tmp_path, path)?;
322
323    Ok(())
324}
325
326/// Outcome of attempting to load the persisted depgraph from a cache directory.
327///
328/// Returned by [`classify_load`] so the daemon can both seed its in-memory
329/// graph and surface the load result to operators (stderr + `last-session.log`).
330/// The variants mirror the failure modes the daemon must handle distinctly:
331///
332/// - `Loaded` — file present, magic + version + payload all valid; the graph
333///   is ready to serve hits from the very first lookup.
334/// - `Missing` — no `depgraph.bin` in the cache dir. Genuine cold start.
335/// - `VersionMismatch` — file present but the embedded version tag does not
336///   match this build. The on-disk format changed since the prior session.
337/// - `Corrupt` — magic mismatch, truncated, or payload validation failed.
338/// - `IoError` — any other I/O failure reading the file.
339#[derive(Debug)]
340pub enum DepGraphLoadOutcome {
341    Loaded {
342        graph: DepGraph,
343    },
344    Missing,
345    VersionMismatch {
346        file_version: u32,
347        expected_version: u32,
348    },
349    Corrupt {
350        message: String,
351    },
352    IoError {
353        message: String,
354    },
355}
356
357impl DepGraphLoadOutcome {
358    /// Returns the loaded graph if this outcome is `Loaded`, else `None`.
359    #[must_use]
360    pub fn into_graph(self) -> Option<DepGraph> {
361        match self {
362            Self::Loaded { graph } => Some(graph),
363            _ => None,
364        }
365    }
366
367    /// Returns a human-readable warning message for non-`Loaded`, non-`Missing`
368    /// outcomes. Used by the daemon to emit a clear notice on stderr AND in the
369    /// per-session log so operators can see exactly why the warm-load failed
370    /// and the session fell back to cold behavior.
371    #[must_use]
372    pub fn warning(&self, path: &Path) -> Option<String> {
373        match self {
374            Self::Loaded { .. } | Self::Missing => None,
375            Self::VersionMismatch {
376                file_version,
377                expected_version,
378            } => Some(format!(
379                "warning: persisted depgraph at {} has version {file_version}, expected {expected_version}; treating session as cold",
380                path.display()
381            )),
382            Self::Corrupt { message } => Some(format!(
383                "warning: persisted depgraph at {} is corrupt ({message}); treating session as cold",
384                path.display()
385            )),
386            Self::IoError { message } => Some(format!(
387                "warning: failed to read persisted depgraph at {} ({message}); treating session as cold",
388                path.display()
389            )),
390        }
391    }
392}
393
394/// Classify a load attempt at `path` into a structured outcome.
395///
396/// This is the load-and-classify helper called by the daemon at startup so a
397/// fresh session pointed at a populated cache dir is automatically treated as
398/// warm — no caller-side opt-in required. See issue #320.
399///
400/// On non-`Loaded` outcomes the returned value carries enough information to
401/// generate a stderr/session-log warning via [`DepGraphLoadOutcome::warning`].
402#[must_use]
403pub fn classify_load(path: &Path) -> DepGraphLoadOutcome {
404    match load_from_file(path) {
405        Ok(graph) => DepGraphLoadOutcome::Loaded { graph },
406        Err(SnapshotError::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
407            DepGraphLoadOutcome::Missing
408        }
409        Err(SnapshotError::Io(e)) => DepGraphLoadOutcome::IoError {
410            message: e.to_string(),
411        },
412        Err(SnapshotError::VersionMismatch { file, expected }) => {
413            DepGraphLoadOutcome::VersionMismatch {
414                file_version: file,
415                expected_version: expected,
416            }
417        }
418        Err(SnapshotError::BadMagic) => DepGraphLoadOutcome::Corrupt {
419            message: "bad magic bytes".into(),
420        },
421        Err(SnapshotError::Corrupt(message)) => DepGraphLoadOutcome::Corrupt { message },
422    }
423}
424
425/// Load the dependency graph from disk, validating header and payload.
426pub fn load_from_file(path: &Path) -> Result<DepGraph, SnapshotError> {
427    let data = std::fs::read(path)?;
428
429    if data.len() < HEADER_SIZE {
430        return Err(SnapshotError::Corrupt("file too small for header".into()));
431    }
432
433    // Validate magic.
434    if data[0..4] != DEPGRAPH_MAGIC {
435        return Err(SnapshotError::BadMagic);
436    }
437
438    // Validate version.
439    let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
440    if version != DEPGRAPH_VERSION {
441        return Err(SnapshotError::VersionMismatch {
442            file: version,
443            expected: DEPGRAPH_VERSION,
444        });
445    }
446
447    // Validate payload length (use checked arithmetic to avoid overflow).
448    let payload_len_u64 = u64::from_le_bytes([
449        data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15],
450    ]);
451    let payload_len: usize = usize::try_from(payload_len_u64).map_err(|_| {
452        SnapshotError::Corrupt(format!("payload length too large: {payload_len_u64}"))
453    })?;
454
455    let available = data.len() - HEADER_SIZE;
456    if available < payload_len {
457        return Err(SnapshotError::Corrupt(format!(
458            "truncated: expected {payload_len} payload bytes, got {available}",
459        )));
460    }
461
462    let payload = &data[HEADER_SIZE..HEADER_SIZE + payload_len];
463
464    // Validate and deserialize.
465    let archived = rkyv::check_archived_root::<DepGraphSnapshot>(payload)
466        .map_err(|e| SnapshotError::Corrupt(format!("validation: {e}")))?;
467
468    let snapshot: DepGraphSnapshot = archived
469        .deserialize(&mut rkyv::Infallible)
470        .expect("infallible deserialization");
471
472    Ok(DepGraph::from_snapshot(snapshot))
473}
474
475// ---------------------------------------------------------------------------
476// Tests
477// ---------------------------------------------------------------------------
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::graph::CacheVerdict;
483    use crate::scanner::ScanResult;
484    use tempfile::TempDir;
485
486    fn test_path(dir: &TempDir) -> NormalizedPath {
487        dir.path().join("depgraph.bin").into()
488    }
489
490    fn make_ctx(source: &str) -> CompileContext {
491        CompileContext {
492            source_file: NormalizedPath::from(source),
493            include_search: IncludeSearchPaths::default(),
494            defines: Vec::new(),
495            flags: Vec::new(),
496            force_includes: Vec::new(),
497            unknown_flags: Vec::new(),
498        }
499    }
500
501    #[test]
502    fn empty_graph_roundtrip() {
503        let dir = TempDir::new().unwrap();
504        let path = test_path(&dir);
505        let graph = DepGraph::new();
506
507        save_to_file(&graph, &path).unwrap();
508        let loaded = load_from_file(&path).unwrap();
509
510        let stats = loaded.stats();
511        assert_eq!(stats.file_count, 0);
512        assert_eq!(stats.context_count, 0);
513    }
514
515    #[test]
516    fn populated_graph_roundtrip() {
517        let dir = TempDir::new().unwrap();
518        let path = test_path(&dir);
519        let graph = DepGraph::new();
520
521        // Add file entries with all IncludeKind variants.
522        graph.store_file_includes(
523            NormalizedPath::from("/src/main.cpp"),
524            vec![
525                IncludeDirective {
526                    kind: IncludeKind::Quoted,
527                    path: "header.h".into(),
528                    line: 1,
529                },
530                IncludeDirective {
531                    kind: IncludeKind::AngleBracket,
532                    path: "vector".into(),
533                    line: 2,
534                },
535                IncludeDirective {
536                    kind: IncludeKind::Computed("PLATFORM_HEADER".into()),
537                    path: "PLATFORM_HEADER".into(),
538                    line: 3,
539                },
540            ],
541        );
542
543        // Add a context entry with all fields populated.
544        let ctx = CompileContext {
545            source_file: NormalizedPath::from("/src/main.cpp"),
546            include_search: IncludeSearchPaths {
547                iquote: vec![NormalizedPath::from("/src")],
548                user: vec![NormalizedPath::from("/include")],
549                system: vec![NormalizedPath::from("/usr/include")],
550                after: vec![NormalizedPath::from("/after")],
551            },
552            defines: vec!["DEBUG=1".into()],
553            flags: vec!["-std=c++17".into()],
554            force_includes: vec![NormalizedPath::from("/pch.h")],
555            unknown_flags: vec!["--custom".into()],
556        };
557        let key = graph.register(ctx);
558
559        // Update with resolved includes and file hashes.
560        let source_hash = zccache_hash::hash_bytes(b"source content");
561        let header_hash = zccache_hash::hash_bytes(b"header content");
562        let pch_hash = zccache_hash::hash_bytes(b"pch content");
563        let hashes: std::collections::HashMap<NormalizedPath, ContentHash> = [
564            (NormalizedPath::from("/src/main.cpp"), source_hash),
565            (NormalizedPath::from("/include/header.h"), header_hash),
566            (NormalizedPath::from("/pch.h"), pch_hash),
567        ]
568        .into_iter()
569        .collect();
570
571        graph.update(
572            &key,
573            ScanResult {
574                resolved: vec![NormalizedPath::from("/include/header.h")],
575                unresolved: vec!["missing.h".into()],
576                has_computed: true,
577            },
578            |path| hashes.get(&NormalizedPath::new(path)).copied(),
579        );
580
581        // Save and load.
582        save_to_file(&graph, &path).unwrap();
583        let loaded = load_from_file(&path).unwrap();
584
585        let stats = loaded.stats();
586        assert_eq!(stats.file_count, 1);
587        assert_eq!(stats.context_count, 1);
588
589        // Verify file entry.
590        let includes = loaded
591            .get_file_includes(&NormalizedPath::from("/src/main.cpp"))
592            .unwrap();
593        assert_eq!(includes.len(), 3);
594        assert_eq!(includes[0].kind, IncludeKind::Quoted);
595        assert_eq!(includes[1].kind, IncludeKind::AngleBracket);
596        assert!(matches!(includes[2].kind, IncludeKind::Computed(_)));
597
598        // Verify context state survived.
599        assert_eq!(loaded.get_state(&key), Some(ContextState::Warm));
600        let resolved = loaded.get_includes(&key).unwrap();
601        assert_eq!(resolved, vec![NormalizedPath::from("/include/header.h")]);
602    }
603
604    #[test]
605    fn version_mismatch() {
606        let dir = TempDir::new().unwrap();
607        let path = test_path(&dir);
608
609        let mut data = Vec::new();
610        data.extend_from_slice(&DEPGRAPH_MAGIC);
611        data.extend_from_slice(&99u32.to_le_bytes());
612        data.extend_from_slice(&0u64.to_le_bytes());
613        std::fs::write(&path, &data).unwrap();
614
615        match load_from_file(&path) {
616            Err(SnapshotError::VersionMismatch {
617                file: 99,
618                expected: DEPGRAPH_VERSION,
619            }) => {}
620            other => panic!("expected VersionMismatch, got {other:?}"),
621        }
622    }
623
624    #[test]
625    fn bad_magic() {
626        let dir = TempDir::new().unwrap();
627        let path = test_path(&dir);
628
629        let mut data = Vec::new();
630        data.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
631        data.extend_from_slice(&DEPGRAPH_VERSION.to_le_bytes());
632        data.extend_from_slice(&0u64.to_le_bytes());
633        std::fs::write(&path, &data).unwrap();
634
635        match load_from_file(&path) {
636            Err(SnapshotError::BadMagic) => {}
637            other => panic!("expected BadMagic, got {other:?}"),
638        }
639    }
640
641    #[test]
642    fn truncated_payload() {
643        let dir = TempDir::new().unwrap();
644        let path = test_path(&dir);
645
646        let mut data = Vec::new();
647        data.extend_from_slice(&DEPGRAPH_MAGIC);
648        data.extend_from_slice(&DEPGRAPH_VERSION.to_le_bytes());
649        data.extend_from_slice(&1000u64.to_le_bytes()); // claims 1000 bytes
650        data.extend_from_slice(&[0u8; 10]); // only 10 bytes
651        std::fs::write(&path, &data).unwrap();
652
653        match load_from_file(&path) {
654            Err(SnapshotError::Corrupt(_)) => {}
655            other => panic!("expected Corrupt, got {other:?}"),
656        }
657    }
658
659    #[test]
660    fn file_not_found() {
661        let dir = TempDir::new().unwrap();
662        let path = dir.path().join("nonexistent.bin");
663
664        match load_from_file(&path) {
665            Err(SnapshotError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {}
666            other => panic!("expected Io(NotFound), got {other:?}"),
667        }
668    }
669
670    #[test]
671    fn atomic_write_cleans_tmp() {
672        let dir = TempDir::new().unwrap();
673        let path = test_path(&dir);
674        let tmp_path = path.with_extension("bin.tmp");
675
676        let graph = DepGraph::new();
677        save_to_file(&graph, &path).unwrap();
678
679        assert!(path.exists());
680        assert!(!tmp_path.exists(), ".tmp file should be cleaned up");
681    }
682
683    #[test]
684    fn last_file_hashes_roundtrip() {
685        let dir = TempDir::new().unwrap();
686        let path = test_path(&dir);
687        let graph = DepGraph::new();
688
689        let key = graph.register(make_ctx("/src/a.cpp"));
690        let hash1 = zccache_hash::hash_bytes(b"content1");
691        let hash2 = zccache_hash::hash_bytes(b"content2");
692        let hashes: std::collections::HashMap<NormalizedPath, ContentHash> = [
693            (NormalizedPath::from("/src/a.cpp"), hash1),
694            (NormalizedPath::from("/inc/b.h"), hash2),
695        ]
696        .into_iter()
697        .collect();
698
699        graph.update(
700            &key,
701            ScanResult {
702                resolved: vec![NormalizedPath::from("/inc/b.h")],
703                unresolved: Vec::new(),
704                has_computed: false,
705            },
706            |path| hashes.get(&NormalizedPath::new(path)).copied(),
707        );
708
709        save_to_file(&graph, &path).unwrap();
710        let loaded = load_from_file(&path).unwrap();
711
712        // Verify context survived with file hashes.
713        assert_eq!(loaded.stats().context_count, 1);
714        assert_eq!(loaded.get_state(&key), Some(ContextState::Warm));
715
716        // Verify hashes via snapshot inspection.
717        let snap = loaded.to_snapshot();
718        assert_eq!(snap.contexts[0].last_file_hashes.len(), 2);
719    }
720
721    #[test]
722    fn artifact_key_some_roundtrip() {
723        let dir = TempDir::new().unwrap();
724        let path = test_path(&dir);
725        let graph = DepGraph::new();
726
727        let key = graph.register(make_ctx("/src/c.cpp"));
728        let hash = zccache_hash::hash_bytes(b"source");
729        let hashes: std::collections::HashMap<NormalizedPath, ContentHash> =
730            [(NormalizedPath::from("/src/c.cpp"), hash)]
731                .into_iter()
732                .collect();
733
734        graph.update(
735            &key,
736            ScanResult {
737                resolved: Vec::new(),
738                unresolved: Vec::new(),
739                has_computed: false,
740            },
741            |path| hashes.get(&NormalizedPath::new(path)).copied(),
742        );
743
744        save_to_file(&graph, &path).unwrap();
745        let loaded = load_from_file(&path).unwrap();
746
747        let snap = loaded.to_snapshot();
748        assert!(
749            snap.contexts[0].artifact_key.is_some(),
750            "artifact_key should survive roundtrip"
751        );
752    }
753
754    #[test]
755    fn gc_trims_old_entries() {
756        let graph = DepGraph::new();
757        graph.register(make_ctx("/old.cpp"));
758        assert_eq!(graph.stats().context_count, 1);
759
760        // trim with zero duration removes all entries.
761        let removed = graph.trim(Duration::ZERO);
762        assert_eq!(removed, 1);
763        assert_eq!(graph.stats().context_count, 0);
764    }
765
766    // ── Adversarial tests ─────────────────────────────────────────────
767
768    fn dummy_hash(path: &std::path::Path) -> Option<ContentHash> {
769        Some(zccache_hash::hash_bytes(path.to_string_lossy().as_bytes()))
770    }
771
772    fn always_fresh(_: &std::path::Path) -> bool {
773        true
774    }
775
776    /// After save+load, a check() on the loaded graph must still return
777    /// Hit for previously-warm contexts. This is the most important
778    /// behavioral invariant.
779    #[test]
780    fn loaded_graph_serves_cache_hits() {
781        let dir = TempDir::new().unwrap();
782        let path = test_path(&dir);
783        let graph = DepGraph::new();
784
785        let ctx = CompileContext {
786            source_file: NormalizedPath::from("/src/main.cpp"),
787            include_search: IncludeSearchPaths {
788                user: vec![NormalizedPath::from("/include")],
789                system: vec![NormalizedPath::from("/usr/include")],
790                ..Default::default()
791            },
792            defines: vec!["NDEBUG".into()],
793            flags: vec!["-O2".into(), "-std=c++17".into()],
794            force_includes: vec![NormalizedPath::from("/pch.h")],
795            unknown_flags: Vec::new(),
796        };
797        let key = graph.register(ctx);
798
799        let hashes: std::collections::HashMap<NormalizedPath, ContentHash> = [
800            (
801                NormalizedPath::from("/src/main.cpp"),
802                zccache_hash::hash_bytes(b"src"),
803            ),
804            (
805                NormalizedPath::from("/include/a.h"),
806                zccache_hash::hash_bytes(b"a"),
807            ),
808            (
809                NormalizedPath::from("/pch.h"),
810                zccache_hash::hash_bytes(b"pch"),
811            ),
812        ]
813        .into_iter()
814        .collect();
815
816        graph.update(
817            &key,
818            ScanResult {
819                resolved: vec![NormalizedPath::from("/include/a.h")],
820                unresolved: Vec::new(),
821                has_computed: false,
822            },
823            |p| hashes.get(&NormalizedPath::new(p)).copied(),
824        );
825
826        // Verify original graph serves hits.
827        let verdict = graph.check(&key, always_fresh, |p| {
828            hashes.get(&NormalizedPath::new(p)).copied()
829        });
830        assert!(
831            matches!(verdict, CacheVerdict::Hit { .. }),
832            "original graph should hit, got {verdict:?}"
833        );
834
835        // Save, load, check again.
836        save_to_file(&graph, &path).unwrap();
837        let loaded = load_from_file(&path).unwrap();
838
839        let verdict = loaded.check(&key, always_fresh, |p| {
840            hashes.get(&NormalizedPath::new(p)).copied()
841        });
842        assert!(
843            matches!(verdict, CacheVerdict::Hit { .. }),
844            "loaded graph should still serve hit, got {verdict:?}"
845        );
846    }
847
848    /// The stored context key must match the key recomputed from the
849    /// loaded CompileContext. If lossy PathBuf→String→NormalizedPath conversion
850    /// corrupts paths, the key will diverge and lookups will silently fail.
851    #[test]
852    fn context_key_consistent_after_roundtrip() {
853        let dir = TempDir::new().unwrap();
854        let path = test_path(&dir);
855        let graph = DepGraph::new();
856
857        let ctx = CompileContext {
858            source_file: NormalizedPath::from("/src/main.cpp"),
859            include_search: IncludeSearchPaths {
860                iquote: vec![NormalizedPath::from("/iquote/dir")],
861                user: vec![NormalizedPath::from("/user/dir")],
862                system: vec![NormalizedPath::from("/system/dir")],
863                after: vec![NormalizedPath::from("/after/dir")],
864            },
865            defines: vec!["FOO=1".into(), "BAR=2".into()],
866            flags: vec!["-Wall".into()],
867            force_includes: vec![NormalizedPath::from("/fi/pch.h")],
868            unknown_flags: vec!["--custom".into()],
869        };
870        let original_key = ctx.context_key();
871        graph.register(ctx);
872
873        let hash = zccache_hash::hash_bytes(b"x");
874        let hashes: std::collections::HashMap<NormalizedPath, ContentHash> = [
875            (NormalizedPath::from("/src/main.cpp"), hash),
876            (NormalizedPath::from("/fi/pch.h"), hash),
877        ]
878        .into_iter()
879        .collect();
880        graph.update(
881            &original_key,
882            ScanResult {
883                resolved: Vec::new(),
884                unresolved: Vec::new(),
885                has_computed: false,
886            },
887            |p| hashes.get(&NormalizedPath::new(p)).copied(),
888        );
889
890        save_to_file(&graph, &path).unwrap();
891        let loaded = load_from_file(&path).unwrap();
892
893        // The loaded graph should find the entry by the original key.
894        assert_eq!(
895            loaded.get_state(&original_key),
896            Some(ContextState::Warm),
897            "loaded graph must find entry by original context key"
898        );
899
900        // Extract the loaded CompileContext and recompute its key.
901        let snap = loaded.to_snapshot();
902        assert_eq!(snap.contexts.len(), 1);
903        let loaded_ctx = CompileContext {
904            source_file: NormalizedPath::from(&snap.contexts[0].source_file),
905            include_search: IncludeSearchPaths {
906                iquote: strings_to_paths(snap.contexts[0].iquote.clone()),
907                user: strings_to_paths(snap.contexts[0].user.clone()),
908                system: strings_to_paths(snap.contexts[0].system.clone()),
909                after: strings_to_paths(snap.contexts[0].after.clone()),
910            },
911            defines: snap.contexts[0].defines.clone(),
912            flags: snap.contexts[0].flags.clone(),
913            force_includes: strings_to_paths(snap.contexts[0].force_includes.clone()),
914            unknown_flags: snap.contexts[0].unknown_flags.clone(),
915        };
916        let recomputed_key = loaded_ctx.context_key();
917        assert_eq!(
918            *original_key.hash().as_bytes(),
919            *recomputed_key.hash().as_bytes(),
920            "context key recomputed from loaded context must match stored key"
921        );
922    }
923
924    /// Unicode paths must roundtrip correctly — they are common on macOS
925    /// (NFC normalization) and Windows (wide chars).
926    #[test]
927    fn unicode_paths_roundtrip() {
928        let dir = TempDir::new().unwrap();
929        let path = test_path(&dir);
930        let graph = DepGraph::new();
931
932        let unicode_source = "/src/日本語/main.cpp";
933        let unicode_header = "/inc/données/header.h";
934        let unicode_define = "NÄME=Ünïcödé";
935        let emoji_path = "/inc/🎉/emoji.h";
936
937        let ctx = CompileContext {
938            source_file: NormalizedPath::from(unicode_source),
939            include_search: IncludeSearchPaths {
940                user: vec![
941                    NormalizedPath::from(unicode_header),
942                    NormalizedPath::from(emoji_path),
943                ],
944                ..Default::default()
945            },
946            defines: vec![unicode_define.into()],
947            flags: Vec::new(),
948            force_includes: Vec::new(),
949            unknown_flags: Vec::new(),
950        };
951        let key = graph.register(ctx);
952        let hash = zccache_hash::hash_bytes(b"x");
953        let hashes: std::collections::HashMap<NormalizedPath, ContentHash> =
954            [(NormalizedPath::from(unicode_source), hash)]
955                .into_iter()
956                .collect();
957        graph.update(
958            &key,
959            ScanResult {
960                resolved: Vec::new(),
961                unresolved: Vec::new(),
962                has_computed: false,
963            },
964            |p| hashes.get(&NormalizedPath::new(p)).copied(),
965        );
966
967        // Also store file includes with unicode paths.
968        graph.store_file_includes(
969            NormalizedPath::from(unicode_source),
970            vec![IncludeDirective {
971                kind: IncludeKind::Quoted,
972                path: unicode_header.into(),
973                line: 1,
974            }],
975        );
976
977        save_to_file(&graph, &path).unwrap();
978        let loaded = load_from_file(&path).unwrap();
979
980        assert_eq!(loaded.get_state(&key), Some(ContextState::Warm));
981        let includes = loaded
982            .get_file_includes(&NormalizedPath::from(unicode_source))
983            .unwrap();
984        assert_eq!(includes[0].path, unicode_header);
985
986        // Verify the context's include search paths survived.
987        let snap = loaded.to_snapshot();
988        assert_eq!(
989            snap.contexts[0].source_file,
990            NormalizedPath::from(unicode_source).display().to_string()
991        );
992        assert!(snap.contexts[0]
993            .user
994            .contains(&NormalizedPath::from(unicode_header).display().to_string()));
995        assert!(snap.contexts[0]
996            .user
997            .contains(&NormalizedPath::from(emoji_path).display().to_string()));
998        assert!(snap.contexts[0]
999            .defines
1000            .contains(&unicode_define.to_string()));
1001    }
1002
1003    /// Save→load→save→load must produce an identical graph. Tests for
1004    /// any drift introduced by a single roundtrip (e.g., path
1005    /// normalization, field reordering, floating precision).
1006    #[test]
1007    fn double_roundtrip_idempotent() {
1008        let dir = TempDir::new().unwrap();
1009        let path1 = dir.path().join("pass1.bin");
1010        let path2 = dir.path().join("pass2.bin");
1011        let graph = DepGraph::new();
1012
1013        // Build a non-trivial graph.
1014        for i in 0..5 {
1015            let ctx = CompileContext {
1016                source_file: NormalizedPath::from(format!("/src/file{i}.cpp")),
1017                include_search: IncludeSearchPaths {
1018                    user: vec![NormalizedPath::from(format!("/inc{i}"))],
1019                    system: vec![NormalizedPath::from("/sys")],
1020                    ..Default::default()
1021                },
1022                defines: vec![format!("VAR{i}=1")],
1023                flags: vec!["-O2".into()],
1024                force_includes: Vec::new(),
1025                unknown_flags: Vec::new(),
1026            };
1027            let key = graph.register(ctx);
1028            graph.update(
1029                &key,
1030                ScanResult {
1031                    resolved: vec![NormalizedPath::from(format!("/inc{i}/h.h"))],
1032                    unresolved: vec![format!("missing{i}.h")],
1033                    has_computed: i == 0, // one with computed includes
1034                },
1035                dummy_hash,
1036            );
1037            graph.store_file_includes(
1038                NormalizedPath::from(format!("/src/file{i}.cpp")),
1039                vec![IncludeDirective {
1040                    kind: IncludeKind::Quoted,
1041                    path: format!("h{i}.h"),
1042                    line: i as u32 + 1,
1043                }],
1044            );
1045        }
1046
1047        // First roundtrip.
1048        save_to_file(&graph, &path1).unwrap();
1049        let loaded1 = load_from_file(&path1).unwrap();
1050
1051        // Second roundtrip.
1052        save_to_file(&loaded1, &path2).unwrap();
1053        let loaded2 = load_from_file(&path2).unwrap();
1054
1055        // Compare snapshots field-by-field.
1056        let snap1 = loaded1.to_snapshot();
1057        let snap2 = loaded2.to_snapshot();
1058        assert_eq!(snap1.files.len(), snap2.files.len(), "file count mismatch");
1059        assert_eq!(
1060            snap1.contexts.len(),
1061            snap2.contexts.len(),
1062            "context count mismatch"
1063        );
1064
1065        // Sort by path for deterministic comparison (DashMap order is random).
1066        let mut files1: Vec<_> = snap1.files.iter().map(|f| &f.path).collect();
1067        let mut files2: Vec<_> = snap2.files.iter().map(|f| &f.path).collect();
1068        files1.sort();
1069        files2.sort();
1070        assert_eq!(files1, files2, "file paths differ after double roundtrip");
1071
1072        let mut keys1: Vec<_> = snap1.contexts.iter().map(|c| c.context_key).collect();
1073        let mut keys2: Vec<_> = snap2.contexts.iter().map(|c| c.context_key).collect();
1074        keys1.sort();
1075        keys2.sort();
1076        assert_eq!(keys1, keys2, "context keys differ after double roundtrip");
1077    }
1078
1079    /// Multiple contexts referencing overlapping resolved includes.
1080    /// All must survive independently.
1081    #[test]
1082    fn overlapping_contexts_roundtrip() {
1083        let dir = TempDir::new().unwrap();
1084        let path = test_path(&dir);
1085        let graph = DepGraph::new();
1086
1087        let shared_header = NormalizedPath::from("/inc/shared.h");
1088
1089        // Two contexts that share the same header.
1090        let ctx_a = CompileContext {
1091            source_file: NormalizedPath::from("/src/a.cpp"),
1092            include_search: IncludeSearchPaths {
1093                user: vec![NormalizedPath::from("/inc")],
1094                ..Default::default()
1095            },
1096            defines: vec!["A=1".into()],
1097            flags: Vec::new(),
1098            force_includes: Vec::new(),
1099            unknown_flags: Vec::new(),
1100        };
1101        let ctx_b = CompileContext {
1102            source_file: NormalizedPath::from("/src/b.cpp"),
1103            include_search: IncludeSearchPaths {
1104                user: vec![NormalizedPath::from("/inc")],
1105                ..Default::default()
1106            },
1107            defines: vec!["B=1".into()],
1108            flags: Vec::new(),
1109            force_includes: Vec::new(),
1110            unknown_flags: Vec::new(),
1111        };
1112
1113        let key_a = graph.register(ctx_a);
1114        let key_b = graph.register(ctx_b);
1115
1116        let hashes: std::collections::HashMap<NormalizedPath, ContentHash> = [
1117            (
1118                NormalizedPath::from("/src/a.cpp"),
1119                zccache_hash::hash_bytes(b"a"),
1120            ),
1121            (
1122                NormalizedPath::from("/src/b.cpp"),
1123                zccache_hash::hash_bytes(b"b"),
1124            ),
1125            (shared_header.clone(), zccache_hash::hash_bytes(b"shared")),
1126        ]
1127        .into_iter()
1128        .collect();
1129
1130        graph.update(
1131            &key_a,
1132            ScanResult {
1133                resolved: vec![shared_header.clone()],
1134                unresolved: Vec::new(),
1135                has_computed: false,
1136            },
1137            |p| hashes.get(&NormalizedPath::new(p)).copied(),
1138        );
1139        graph.update(
1140            &key_b,
1141            ScanResult {
1142                resolved: vec![shared_header.clone()],
1143                unresolved: Vec::new(),
1144                has_computed: false,
1145            },
1146            |p| hashes.get(&NormalizedPath::new(p)).copied(),
1147        );
1148
1149        save_to_file(&graph, &path).unwrap();
1150        let loaded = load_from_file(&path).unwrap();
1151
1152        assert_eq!(loaded.stats().context_count, 2);
1153        assert_eq!(loaded.get_state(&key_a), Some(ContextState::Warm));
1154        assert_eq!(loaded.get_state(&key_b), Some(ContextState::Warm));
1155
1156        // Both should serve hits.
1157        let verdict_a = loaded.check(&key_a, always_fresh, |p| {
1158            hashes.get(&NormalizedPath::new(p)).copied()
1159        });
1160        let verdict_b = loaded.check(&key_b, always_fresh, |p| {
1161            hashes.get(&NormalizedPath::new(p)).copied()
1162        });
1163        assert!(matches!(verdict_a, CacheVerdict::Hit { .. }));
1164        assert!(matches!(verdict_b, CacheVerdict::Hit { .. }));
1165
1166        // And they must have different artifact keys (different source files).
1167        match (verdict_a, verdict_b) {
1168            (
1169                CacheVerdict::Hit { artifact_key: ak_a },
1170                CacheVerdict::Hit { artifact_key: ak_b },
1171            ) => {
1172                assert_ne!(
1173                    ak_a.hash().as_bytes(),
1174                    ak_b.hash().as_bytes(),
1175                    "different contexts should have different artifact keys"
1176                );
1177            }
1178            _ => unreachable!(),
1179        }
1180    }
1181
1182    /// All three ContextState variants must survive roundtrip faithfully.
1183    #[test]
1184    fn all_states_roundtrip() {
1185        let dir = TempDir::new().unwrap();
1186        let path = test_path(&dir);
1187        let graph = DepGraph::new();
1188
1189        // Cold context: just register, never update.
1190        let cold_key = graph.register(make_ctx("/src/cold.cpp"));
1191        assert_eq!(graph.get_state(&cold_key), Some(ContextState::Cold));
1192
1193        // Warm context: register + update.
1194        let warm_key = graph.register(make_ctx("/src/warm.cpp"));
1195        graph.update(
1196            &warm_key,
1197            ScanResult {
1198                resolved: Vec::new(),
1199                unresolved: Vec::new(),
1200                has_computed: false,
1201            },
1202            dummy_hash,
1203        );
1204        assert_eq!(graph.get_state(&warm_key), Some(ContextState::Warm));
1205
1206        // Stale context: register + update + mark stale.
1207        let stale_key = graph.register(make_ctx("/src/stale.cpp"));
1208        graph.update(
1209            &stale_key,
1210            ScanResult {
1211                resolved: Vec::new(),
1212                unresolved: Vec::new(),
1213                has_computed: false,
1214            },
1215            dummy_hash,
1216        );
1217        graph.mark_stale(&stale_key);
1218        assert_eq!(graph.get_state(&stale_key), Some(ContextState::Stale));
1219
1220        save_to_file(&graph, &path).unwrap();
1221        let loaded = load_from_file(&path).unwrap();
1222
1223        assert_eq!(
1224            loaded.get_state(&cold_key),
1225            Some(ContextState::Cold),
1226            "Cold state not preserved"
1227        );
1228        assert_eq!(
1229            loaded.get_state(&warm_key),
1230            Some(ContextState::Warm),
1231            "Warm state not preserved"
1232        );
1233        assert_eq!(
1234            loaded.get_state(&stale_key),
1235            Some(ContextState::Stale),
1236            "Stale state not preserved"
1237        );
1238    }
1239
1240    /// A bit-flip in the rkyv payload should be caught by validation.
1241    #[test]
1242    fn bit_flip_in_payload_detected() {
1243        let dir = TempDir::new().unwrap();
1244        let path = test_path(&dir);
1245        let graph = DepGraph::new();
1246
1247        let key = graph.register(make_ctx("/src/a.cpp"));
1248        graph.update(
1249            &key,
1250            ScanResult {
1251                resolved: vec![NormalizedPath::from("/inc/b.h")],
1252                unresolved: Vec::new(),
1253                has_computed: false,
1254            },
1255            dummy_hash,
1256        );
1257
1258        save_to_file(&graph, &path).unwrap();
1259
1260        // Read the file, flip a byte in the payload, write it back.
1261        let mut data = std::fs::read(&path).unwrap();
1262        assert!(data.len() > HEADER_SIZE + 10);
1263        // Flip a bit in the middle of the payload.
1264        let flip_idx = HEADER_SIZE + (data.len() - HEADER_SIZE) / 2;
1265        data[flip_idx] ^= 0xFF;
1266        std::fs::write(&path, &data).unwrap();
1267
1268        match load_from_file(&path) {
1269            Err(SnapshotError::Corrupt(_)) => {} // Expected
1270            Ok(_) => {
1271                // rkyv might not catch every bit-flip if it lands on
1272                // a valid-looking field. This is acceptable — we just
1273                // want to verify the validation path exists.
1274            }
1275            Err(other) => panic!("unexpected error: {other}"),
1276        }
1277    }
1278
1279    /// Empty strings in all fields must not cause panics or data loss.
1280    #[test]
1281    fn empty_strings_roundtrip() {
1282        let dir = TempDir::new().unwrap();
1283        let path = test_path(&dir);
1284        let graph = DepGraph::new();
1285
1286        let ctx = CompileContext {
1287            source_file: NormalizedPath::from(""),
1288            include_search: IncludeSearchPaths {
1289                iquote: vec![NormalizedPath::from("")],
1290                user: vec![NormalizedPath::from("")],
1291                system: vec![NormalizedPath::from("")],
1292                after: vec![NormalizedPath::from("")],
1293            },
1294            defines: vec![String::new()],
1295            flags: vec![String::new()],
1296            force_includes: vec![NormalizedPath::from("")],
1297            unknown_flags: vec![String::new()],
1298        };
1299        let key = graph.register(ctx);
1300
1301        // Empty path hash.
1302        let hash = zccache_hash::hash_bytes(b"");
1303        let hashes: std::collections::HashMap<NormalizedPath, ContentHash> =
1304            [(NormalizedPath::from(""), hash)].into_iter().collect();
1305        graph.update(
1306            &key,
1307            ScanResult {
1308                resolved: Vec::new(),
1309                unresolved: vec![String::new()],
1310                has_computed: false,
1311            },
1312            |p| hashes.get(&NormalizedPath::new(p)).copied(),
1313        );
1314
1315        save_to_file(&graph, &path).unwrap();
1316        let loaded = load_from_file(&path).unwrap();
1317
1318        assert_eq!(loaded.stats().context_count, 1);
1319        assert_eq!(loaded.get_state(&key), Some(ContextState::Warm));
1320
1321        let snap = loaded.to_snapshot();
1322        assert_eq!(snap.contexts[0].source_file, "");
1323        assert_eq!(snap.contexts[0].defines, vec![""]);
1324        assert_eq!(snap.contexts[0].unresolved_includes, vec![""]);
1325    }
1326
1327    /// Stress test: many contexts + files to verify no panics, no data
1328    /// loss, and reasonable performance.
1329    #[test]
1330    fn large_graph_roundtrip() {
1331        let dir = TempDir::new().unwrap();
1332        let path = test_path(&dir);
1333        let graph = DepGraph::new();
1334
1335        let n_contexts = 200;
1336        let n_headers_per_ctx = 10;
1337        let mut keys = Vec::new();
1338
1339        for i in 0..n_contexts {
1340            let ctx = CompileContext {
1341                source_file: NormalizedPath::from(format!("/src/file{i}.cpp")),
1342                include_search: IncludeSearchPaths {
1343                    user: vec![NormalizedPath::from(format!("/inc{i}"))],
1344                    ..Default::default()
1345                },
1346                defines: (0..5).map(|d| format!("DEF{d}={i}")).collect(),
1347                flags: vec!["-O2".into(), format!("-std=c++{}", 14 + (i % 4) * 3)],
1348                force_includes: Vec::new(),
1349                unknown_flags: Vec::new(),
1350            };
1351            let key = graph.register(ctx);
1352
1353            let resolved: Vec<NormalizedPath> = (0..n_headers_per_ctx)
1354                .map(|h| NormalizedPath::from(format!("/inc{i}/header{h}.h")))
1355                .collect();
1356            graph.update(
1357                &key,
1358                ScanResult {
1359                    resolved,
1360                    unresolved: Vec::new(),
1361                    has_computed: false,
1362                },
1363                dummy_hash,
1364            );
1365            keys.push(key);
1366        }
1367
1368        assert_eq!(graph.stats().context_count, n_contexts);
1369
1370        save_to_file(&graph, &path).unwrap();
1371        let loaded = load_from_file(&path).unwrap();
1372
1373        assert_eq!(loaded.stats().context_count, n_contexts);
1374
1375        // Spot-check a few contexts.
1376        for key in keys.iter().take(10) {
1377            assert_eq!(loaded.get_state(key), Some(ContextState::Warm));
1378            let verdict = loaded.check(key, always_fresh, dummy_hash);
1379            assert!(
1380                matches!(verdict, CacheVerdict::Hit { .. }),
1381                "context should hit after load"
1382            );
1383        }
1384    }
1385
1386    /// Overwriting an existing snapshot file must work (tests the
1387    /// Windows remove-before-rename path).
1388    #[test]
1389    fn overwrite_existing_file() {
1390        let dir = TempDir::new().unwrap();
1391        let path = test_path(&dir);
1392
1393        // First save.
1394        let graph1 = DepGraph::new();
1395        graph1.register(make_ctx("/src/old.cpp"));
1396        save_to_file(&graph1, &path).unwrap();
1397
1398        // Second save with different content.
1399        let graph2 = DepGraph::new();
1400        let key = graph2.register(make_ctx("/src/new.cpp"));
1401        graph2.update(
1402            &key,
1403            ScanResult {
1404                resolved: Vec::new(),
1405                unresolved: Vec::new(),
1406                has_computed: false,
1407            },
1408            dummy_hash,
1409        );
1410        save_to_file(&graph2, &path).unwrap();
1411
1412        // Load should see the second graph.
1413        let loaded = load_from_file(&path).unwrap();
1414        assert_eq!(loaded.stats().context_count, 1);
1415        assert_eq!(loaded.get_state(&key), Some(ContextState::Warm));
1416    }
1417
1418    /// A file with correct header but zero-length payload.
1419    #[test]
1420    fn zero_length_payload_rejected() {
1421        let dir = TempDir::new().unwrap();
1422        let path = test_path(&dir);
1423
1424        let mut data = Vec::new();
1425        data.extend_from_slice(&DEPGRAPH_MAGIC);
1426        data.extend_from_slice(&DEPGRAPH_VERSION.to_le_bytes());
1427        data.extend_from_slice(&0u64.to_le_bytes()); // zero-length payload
1428        std::fs::write(&path, &data).unwrap();
1429
1430        // rkyv should reject an empty payload.
1431        match load_from_file(&path) {
1432            Err(SnapshotError::Corrupt(_)) => {}
1433            other => panic!("expected Corrupt for empty payload, got {other:?}"),
1434        }
1435    }
1436
1437    /// Just the magic bytes and nothing else — shorter than header.
1438    #[test]
1439    fn header_too_short() {
1440        let dir = TempDir::new().unwrap();
1441        let path = test_path(&dir);
1442
1443        std::fs::write(&path, DEPGRAPH_MAGIC).unwrap();
1444
1445        match load_from_file(&path) {
1446            Err(SnapshotError::Corrupt(msg)) => {
1447                assert!(msg.contains("too small"), "unexpected message: {msg}");
1448            }
1449            other => panic!("expected Corrupt, got {other:?}"),
1450        }
1451    }
1452
1453    /// Artifact key=None (Cold context) must roundtrip as None.
1454    #[test]
1455    fn artifact_key_none_roundtrip() {
1456        let dir = TempDir::new().unwrap();
1457        let path = test_path(&dir);
1458        let graph = DepGraph::new();
1459
1460        // Register but don't update — artifact_key stays None.
1461        graph.register(make_ctx("/src/cold.cpp"));
1462
1463        save_to_file(&graph, &path).unwrap();
1464        let loaded = load_from_file(&path).unwrap();
1465
1466        let snap = loaded.to_snapshot();
1467        assert_eq!(snap.contexts.len(), 1);
1468        assert!(
1469            snap.contexts[0].artifact_key.is_none(),
1470            "Cold context should have artifact_key=None"
1471        );
1472    }
1473
1474    /// Unresolved includes (strings, not paths) must roundtrip.
1475    #[test]
1476    fn unresolved_includes_roundtrip() {
1477        let dir = TempDir::new().unwrap();
1478        let path = test_path(&dir);
1479        let graph = DepGraph::new();
1480
1481        let key = graph.register(make_ctx("/src/a.cpp"));
1482        graph.update(
1483            &key,
1484            ScanResult {
1485                resolved: Vec::new(),
1486                unresolved: vec!["missing1.h".into(), "subdir/missing2.h".into(), "".into()],
1487                has_computed: false,
1488            },
1489            dummy_hash,
1490        );
1491
1492        save_to_file(&graph, &path).unwrap();
1493        let loaded = load_from_file(&path).unwrap();
1494
1495        let snap = loaded.to_snapshot();
1496        assert_eq!(
1497            snap.contexts[0].unresolved_includes,
1498            vec!["missing1.h", "subdir/missing2.h", ""]
1499        );
1500    }
1501
1502    /// has_computed_includes flag must roundtrip for both true and false.
1503    #[test]
1504    fn has_computed_includes_roundtrip() {
1505        let dir = TempDir::new().unwrap();
1506        let path = test_path(&dir);
1507        let graph = DepGraph::new();
1508
1509        let key_with = graph.register(make_ctx("/src/with_computed.cpp"));
1510        graph.update(
1511            &key_with,
1512            ScanResult {
1513                resolved: Vec::new(),
1514                unresolved: Vec::new(),
1515                has_computed: true,
1516            },
1517            dummy_hash,
1518        );
1519
1520        let key_without = graph.register(make_ctx("/src/without_computed.cpp"));
1521        graph.update(
1522            &key_without,
1523            ScanResult {
1524                resolved: Vec::new(),
1525                unresolved: Vec::new(),
1526                has_computed: false,
1527            },
1528            dummy_hash,
1529        );
1530
1531        save_to_file(&graph, &path).unwrap();
1532        let loaded = load_from_file(&path).unwrap();
1533
1534        let snap = loaded.to_snapshot();
1535        let with_computed = NormalizedPath::new("/src/with_computed.cpp")
1536            .display()
1537            .to_string();
1538        let without_computed = NormalizedPath::new("/src/without_computed.cpp")
1539            .display()
1540            .to_string();
1541        let ctx_with = snap
1542            .contexts
1543            .iter()
1544            .find(|c| c.source_file == with_computed)
1545            .unwrap();
1546        let ctx_without = snap
1547            .contexts
1548            .iter()
1549            .find(|c| c.source_file == without_computed)
1550            .unwrap();
1551        assert!(ctx_with.has_computed_includes);
1552        assert!(!ctx_without.has_computed_includes);
1553
1554        // Warm context with has_computed must return NeedsPreprocessor on check.
1555        let verdict = loaded.check(&key_with, always_fresh, dummy_hash);
1556        assert!(
1557            matches!(verdict, CacheVerdict::NeedsPreprocessor),
1558            "computed includes should force preprocessor, got {verdict:?}"
1559        );
1560    }
1561
1562    /// All three IncludeKind variants in file entries must roundtrip,
1563    /// including the inner string of Computed.
1564    #[test]
1565    fn include_kind_computed_inner_string_roundtrip() {
1566        let dir = TempDir::new().unwrap();
1567        let path = test_path(&dir);
1568        let graph = DepGraph::new();
1569
1570        let macro_name = "MY_PLATFORM_HEADER";
1571        graph.store_file_includes(
1572            NormalizedPath::from("/src/test.cpp"),
1573            vec![
1574                IncludeDirective {
1575                    kind: IncludeKind::Quoted,
1576                    path: "local.h".into(),
1577                    line: 1,
1578                },
1579                IncludeDirective {
1580                    kind: IncludeKind::AngleBracket,
1581                    path: "system.h".into(),
1582                    line: 2,
1583                },
1584                IncludeDirective {
1585                    kind: IncludeKind::Computed(macro_name.into()),
1586                    path: macro_name.into(),
1587                    line: 3,
1588                },
1589            ],
1590        );
1591
1592        // Need a context that references this file so trim doesn't remove it.
1593        let key = graph.register(make_ctx("/src/test.cpp"));
1594        graph.update(
1595            &key,
1596            ScanResult {
1597                resolved: Vec::new(),
1598                unresolved: Vec::new(),
1599                has_computed: true,
1600            },
1601            dummy_hash,
1602        );
1603
1604        save_to_file(&graph, &path).unwrap();
1605        let loaded = load_from_file(&path).unwrap();
1606
1607        let includes = loaded
1608            .get_file_includes(&NormalizedPath::from("/src/test.cpp"))
1609            .unwrap();
1610        assert_eq!(includes.len(), 3);
1611        assert_eq!(includes[0].kind, IncludeKind::Quoted);
1612        assert_eq!(includes[0].path, "local.h");
1613        assert_eq!(includes[1].kind, IncludeKind::AngleBracket);
1614        assert_eq!(includes[1].path, "system.h");
1615        match &includes[2].kind {
1616            IncludeKind::Computed(inner) => {
1617                assert_eq!(
1618                    inner, macro_name,
1619                    "Computed inner string must survive roundtrip"
1620                );
1621            }
1622            other => panic!("expected Computed, got {other:?}"),
1623        }
1624        assert_eq!(includes[2].line, 3);
1625    }
1626
1627    /// A new compile request for the same context after loading must
1628    /// find the existing warm entry (not create a duplicate cold one).
1629    #[test]
1630    fn register_after_load_finds_existing() {
1631        let dir = TempDir::new().unwrap();
1632        let path = test_path(&dir);
1633        let graph = DepGraph::new();
1634
1635        let ctx = CompileContext {
1636            source_file: NormalizedPath::from("/src/main.cpp"),
1637            include_search: IncludeSearchPaths {
1638                user: vec![NormalizedPath::from("/inc")],
1639                ..Default::default()
1640            },
1641            defines: vec!["X=1".into()],
1642            flags: Vec::new(),
1643            force_includes: Vec::new(),
1644            unknown_flags: Vec::new(),
1645        };
1646        let original_key = graph.register(ctx.clone());
1647        graph.update(
1648            &original_key,
1649            ScanResult {
1650                resolved: vec![NormalizedPath::from("/inc/a.h")],
1651                unresolved: Vec::new(),
1652                has_computed: false,
1653            },
1654            dummy_hash,
1655        );
1656
1657        save_to_file(&graph, &path).unwrap();
1658        let loaded = load_from_file(&path).unwrap();
1659
1660        // Simulate a new compile request with the identical context.
1661        let new_key = loaded.register(ctx);
1662        assert_eq!(
1663            original_key.hash().as_bytes(),
1664            new_key.hash().as_bytes(),
1665            "re-registering same context must produce same key"
1666        );
1667        // The existing warm entry must still be there (not overwritten).
1668        assert_eq!(
1669            loaded.get_state(&new_key),
1670            Some(ContextState::Warm),
1671            "re-register must not overwrite warm entry with cold"
1672        );
1673        assert_eq!(
1674            loaded.stats().context_count,
1675            1,
1676            "re-register must not create duplicate"
1677        );
1678    }
1679
1680    /// File hashes must roundtrip with exact byte equality.
1681    #[test]
1682    fn file_hash_bytes_exact_roundtrip() {
1683        let dir = TempDir::new().unwrap();
1684        let path = test_path(&dir);
1685        let graph = DepGraph::new();
1686
1687        let key = graph.register(make_ctx("/src/a.cpp"));
1688        let source_hash = zccache_hash::hash_bytes(b"specific source content 12345");
1689        let header_hash = zccache_hash::hash_bytes(b"specific header content 67890");
1690
1691        let hashes: std::collections::HashMap<NormalizedPath, ContentHash> = [
1692            (NormalizedPath::from("/src/a.cpp"), source_hash),
1693            (NormalizedPath::from("/inc/b.h"), header_hash),
1694        ]
1695        .into_iter()
1696        .collect();
1697
1698        graph.update(
1699            &key,
1700            ScanResult {
1701                resolved: vec![NormalizedPath::from("/inc/b.h")],
1702                unresolved: Vec::new(),
1703                has_computed: false,
1704            },
1705            |p| hashes.get(&NormalizedPath::new(p)).copied(),
1706        );
1707
1708        save_to_file(&graph, &path).unwrap();
1709        let loaded = load_from_file(&path).unwrap();
1710
1711        let snap = loaded.to_snapshot();
1712        let ctx = &snap.contexts[0];
1713
1714        // Verify each hash byte-for-byte.
1715        for (snap_path, snap_hash) in &ctx.last_file_hashes {
1716            let expected = hashes.get(&NormalizedPath::from(snap_path)).unwrap();
1717            assert_eq!(
1718                snap_hash,
1719                expected.as_bytes(),
1720                "hash mismatch for {snap_path}"
1721            );
1722        }
1723    }
1724
1725    /// Artifact key bytes must be identical after roundtrip.
1726    #[test]
1727    fn artifact_key_bytes_exact_roundtrip() {
1728        let dir = TempDir::new().unwrap();
1729        let path = test_path(&dir);
1730        let graph = DepGraph::new();
1731
1732        let key = graph.register(make_ctx("/src/a.cpp"));
1733        let artifact = graph
1734            .update(
1735                &key,
1736                ScanResult {
1737                    resolved: Vec::new(),
1738                    unresolved: Vec::new(),
1739                    has_computed: false,
1740                },
1741                dummy_hash,
1742            )
1743            .unwrap();
1744
1745        save_to_file(&graph, &path).unwrap();
1746        let loaded = load_from_file(&path).unwrap();
1747
1748        let snap = loaded.to_snapshot();
1749        let loaded_artifact_bytes = snap.contexts[0].artifact_key.unwrap();
1750        assert_eq!(
1751            &loaded_artifact_bytes,
1752            artifact.hash().as_bytes(),
1753            "artifact key bytes must be identical after roundtrip"
1754        );
1755    }
1756
1757    /// GC during save must not discard recently-accessed warm contexts.
1758    #[test]
1759    fn gc_on_save_preserves_fresh_entries() {
1760        let dir = TempDir::new().unwrap();
1761        let path = test_path(&dir);
1762        let graph = DepGraph::new();
1763
1764        // Register and update 10 contexts.
1765        let mut keys = Vec::new();
1766        for i in 0..10 {
1767            let key = graph.register(make_ctx(&format!("/src/f{i}.cpp")));
1768            graph.update(
1769                &key,
1770                ScanResult {
1771                    resolved: Vec::new(),
1772                    unresolved: Vec::new(),
1773                    has_computed: false,
1774                },
1775                dummy_hash,
1776            );
1777            keys.push(key);
1778        }
1779
1780        // Save triggers GC (1-day TTL). All entries are fresh, so none should be trimmed.
1781        save_to_file(&graph, &path).unwrap();
1782        let loaded = load_from_file(&path).unwrap();
1783
1784        assert_eq!(
1785            loaded.stats().context_count,
1786            10,
1787            "GC should not trim fresh entries"
1788        );
1789        for key in &keys {
1790            assert_eq!(loaded.get_state(key), Some(ContextState::Warm));
1791        }
1792    }
1793
1794    /// Stats counters must reset to zero after load (not carry forward
1795    /// stale hit/miss data).
1796    #[test]
1797    fn stats_reset_after_load() {
1798        let dir = TempDir::new().unwrap();
1799        let path = test_path(&dir);
1800        let graph = DepGraph::new();
1801
1802        let key = graph.register(make_ctx("/src/a.cpp"));
1803        graph.update(
1804            &key,
1805            ScanResult {
1806                resolved: Vec::new(),
1807                unresolved: Vec::new(),
1808                has_computed: false,
1809            },
1810            dummy_hash,
1811        );
1812        // Generate some stats.
1813        graph.check(&key, always_fresh, dummy_hash);
1814        graph.check(&key, always_fresh, dummy_hash);
1815        assert_eq!(graph.stats().checks, 2);
1816        assert_eq!(graph.stats().hits, 2);
1817
1818        save_to_file(&graph, &path).unwrap();
1819        let loaded = load_from_file(&path).unwrap();
1820
1821        let stats = loaded.stats();
1822        assert_eq!(stats.checks, 0, "checks must reset on load");
1823        assert_eq!(stats.hits, 0, "hits must reset on load");
1824        assert_eq!(stats.misses, 0, "misses must reset on load");
1825    }
1826
1827    /// Payload with trailing garbage bytes after the declared length.
1828    /// The loader should ignore trailing data (only read payload_len bytes).
1829    #[test]
1830    fn trailing_garbage_after_payload_ignored() {
1831        let dir = TempDir::new().unwrap();
1832        let path = test_path(&dir);
1833        let graph = DepGraph::new();
1834
1835        let key = graph.register(make_ctx("/src/a.cpp"));
1836        graph.update(
1837            &key,
1838            ScanResult {
1839                resolved: Vec::new(),
1840                unresolved: Vec::new(),
1841                has_computed: false,
1842            },
1843            dummy_hash,
1844        );
1845        save_to_file(&graph, &path).unwrap();
1846
1847        // Append garbage to the file.
1848        let mut data = std::fs::read(&path).unwrap();
1849        data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF]);
1850        std::fs::write(&path, &data).unwrap();
1851
1852        // Should still load fine — trailing data is beyond payload_len.
1853        let loaded = load_from_file(&path).unwrap();
1854        assert_eq!(loaded.stats().context_count, 1);
1855        assert_eq!(loaded.get_state(&key), Some(ContextState::Warm));
1856    }
1857
1858    /// Concurrent save + load should not panic or corrupt (thread safety).
1859    #[test]
1860    fn concurrent_save_load() {
1861        use std::sync::Arc;
1862
1863        let dir = TempDir::new().unwrap();
1864        let path = test_path(&dir);
1865        let graph = Arc::new(DepGraph::new());
1866
1867        // Populate the graph.
1868        for i in 0..50 {
1869            let key = graph.register(make_ctx(&format!("/src/f{i}.cpp")));
1870            graph.update(
1871                &key,
1872                ScanResult {
1873                    resolved: vec![NormalizedPath::from(format!("/inc/h{i}.h"))],
1874                    unresolved: Vec::new(),
1875                    has_computed: false,
1876                },
1877                dummy_hash,
1878            );
1879        }
1880
1881        // Save once so the file exists.
1882        save_to_file(&graph, &path).unwrap();
1883
1884        let mut handles = Vec::new();
1885
1886        // Writer threads.
1887        for _ in 0..3 {
1888            let g = Arc::clone(&graph);
1889            let p = path.clone();
1890            handles.push(std::thread::spawn(move || {
1891                for _ in 0..5 {
1892                    let _ = save_to_file(&g, &p);
1893                }
1894            }));
1895        }
1896
1897        // Reader threads.
1898        for _ in 0..3 {
1899            let p = path.clone();
1900            handles.push(std::thread::spawn(move || {
1901                for _ in 0..5 {
1902                    // May fail if file is being rewritten — that's OK.
1903                    let _ = load_from_file(&p);
1904                }
1905            }));
1906        }
1907
1908        // Mutator threads (add new entries while saving).
1909        for t in 0..2 {
1910            let g = Arc::clone(&graph);
1911            handles.push(std::thread::spawn(move || {
1912                for i in 0..20 {
1913                    let key = g.register(make_ctx(&format!("/src/t{t}_new{i}.cpp")));
1914                    g.update(
1915                        &key,
1916                        ScanResult {
1917                            resolved: Vec::new(),
1918                            unresolved: Vec::new(),
1919                            has_computed: false,
1920                        },
1921                        dummy_hash,
1922                    );
1923                }
1924            }));
1925        }
1926
1927        for h in handles {
1928            h.join().expect("thread panicked");
1929        }
1930
1931        // Final save+load should be consistent.
1932        save_to_file(&graph, &path).unwrap();
1933        let loaded = load_from_file(&path).unwrap();
1934        assert!(loaded.stats().context_count >= 50);
1935    }
1936
1937    /// A crafted file with payload_len = u64::MAX must not panic or cause
1938    /// undefined behavior. The addition HEADER_SIZE + payload_len overflows
1939    /// usize, which panics in debug mode and wraps in release.
1940    #[test]
1941    fn payload_length_overflow_u64_max() {
1942        let dir = TempDir::new().unwrap();
1943        let path = test_path(&dir);
1944
1945        let mut data = Vec::new();
1946        data.extend_from_slice(&DEPGRAPH_MAGIC);
1947        data.extend_from_slice(&DEPGRAPH_VERSION.to_le_bytes());
1948        data.extend_from_slice(&u64::MAX.to_le_bytes());
1949        data.extend_from_slice(&[0u8; 64]); // some payload bytes
1950        std::fs::write(&path, &data).unwrap();
1951
1952        // Must return an error, not panic.
1953        assert!(
1954            load_from_file(&path).is_err(),
1955            "u64::MAX payload_len must be rejected"
1956        );
1957    }
1958
1959    /// payload_len = usize::MAX - HEADER_SIZE + 1 causes overflow of
1960    /// HEADER_SIZE + payload_len.
1961    #[test]
1962    fn payload_length_overflow_boundary() {
1963        let dir = TempDir::new().unwrap();
1964        let path = test_path(&dir);
1965
1966        // This value causes HEADER_SIZE + payload_len to wrap to exactly 0.
1967        let evil_len = (usize::MAX - HEADER_SIZE).wrapping_add(1) as u64;
1968
1969        let mut data = Vec::new();
1970        data.extend_from_slice(&DEPGRAPH_MAGIC);
1971        data.extend_from_slice(&DEPGRAPH_VERSION.to_le_bytes());
1972        data.extend_from_slice(&evil_len.to_le_bytes());
1973        data.extend_from_slice(&[0u8; 64]);
1974        std::fs::write(&path, &data).unwrap();
1975
1976        // Must return an error, not panic.
1977        assert!(
1978            load_from_file(&path).is_err(),
1979            "overflow-inducing payload_len must be rejected"
1980        );
1981    }
1982
1983    // ── classify_load tests (issue #320) ─────────────────────────────────────
1984
1985    #[test]
1986    fn classify_load_missing_returns_missing() {
1987        let dir = TempDir::new().unwrap();
1988        let path = dir.path().join("absent.bin");
1989
1990        let outcome = classify_load(&path);
1991        assert!(matches!(outcome, DepGraphLoadOutcome::Missing));
1992        assert!(outcome.warning(&path).is_none(), "Missing must not warn");
1993        assert!(outcome.into_graph().is_none());
1994    }
1995
1996    #[test]
1997    fn classify_load_valid_returns_loaded() {
1998        let dir = TempDir::new().unwrap();
1999        let path = test_path(&dir);
2000
2001        let graph = DepGraph::new();
2002        let _ = graph.register(make_ctx("/src/main.cpp"));
2003        save_to_file(&graph, &path).unwrap();
2004
2005        let outcome = classify_load(&path);
2006        assert!(matches!(outcome, DepGraphLoadOutcome::Loaded { .. }));
2007        assert!(outcome.warning(&path).is_none(), "Loaded must not warn");
2008        let loaded = outcome.into_graph().expect("Loaded must yield graph");
2009        assert_eq!(loaded.stats().context_count, 1);
2010    }
2011
2012    #[test]
2013    fn classify_load_version_mismatch_warns() {
2014        let dir = TempDir::new().unwrap();
2015        let path = test_path(&dir);
2016
2017        let mut data = Vec::new();
2018        data.extend_from_slice(&DEPGRAPH_MAGIC);
2019        data.extend_from_slice(&99u32.to_le_bytes());
2020        data.extend_from_slice(&0u64.to_le_bytes());
2021        std::fs::write(&path, &data).unwrap();
2022
2023        let outcome = classify_load(&path);
2024        match &outcome {
2025            DepGraphLoadOutcome::VersionMismatch {
2026                file_version: 99,
2027                expected_version,
2028            } => {
2029                assert_eq!(*expected_version, DEPGRAPH_VERSION);
2030            }
2031            other => panic!("expected VersionMismatch, got {other:?}"),
2032        }
2033        let warning = outcome.warning(&path).expect("must warn");
2034        assert!(warning.contains("version 99"));
2035        assert!(warning.contains("treating session as cold"));
2036    }
2037
2038    #[test]
2039    fn classify_load_bad_magic_is_corrupt() {
2040        let dir = TempDir::new().unwrap();
2041        let path = test_path(&dir);
2042
2043        let mut data = Vec::new();
2044        data.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
2045        data.extend_from_slice(&DEPGRAPH_VERSION.to_le_bytes());
2046        data.extend_from_slice(&0u64.to_le_bytes());
2047        std::fs::write(&path, &data).unwrap();
2048
2049        let outcome = classify_load(&path);
2050        assert!(matches!(outcome, DepGraphLoadOutcome::Corrupt { .. }));
2051        let warning = outcome.warning(&path).expect("must warn");
2052        assert!(warning.contains("corrupt"));
2053        assert!(warning.contains("treating session as cold"));
2054    }
2055
2056    #[test]
2057    fn classify_load_truncated_is_corrupt() {
2058        let dir = TempDir::new().unwrap();
2059        let path = test_path(&dir);
2060
2061        // Too small to even hold the header.
2062        std::fs::write(&path, [0x5Au8, 0x43, 0x44]).unwrap();
2063
2064        let outcome = classify_load(&path);
2065        assert!(matches!(outcome, DepGraphLoadOutcome::Corrupt { .. }));
2066        assert!(outcome.warning(&path).is_some());
2067    }
2068}