1use 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
20pub const DEPGRAPH_VERSION: u32 = 4;
22
23pub const DEPGRAPH_MAGIC: [u8; 4] = [0x5A, 0x43, 0x44, 0x47];
25
26const HEADER_SIZE: usize = 16;
28
29const GC_TTL: Duration = Duration::from_secs(86_400); const SERIALIZE_SCRATCH: usize = 4096;
34
35#[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 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 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#[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
113impl DepGraph {
118 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 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#[must_use]
283pub fn depgraph_file_path() -> NormalizedPath {
284 zccache_core::config::depgraph_dir().join("depgraph.bin")
285}
286
287pub fn save_to_file(graph: &DepGraph, path: &Path) -> Result<(), SnapshotError> {
291 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 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 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 let _ = std::fs::remove_file(path);
321 std::fs::rename(&tmp_path, path)?;
322
323 Ok(())
324}
325
326#[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 #[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 #[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#[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
425pub 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 if data[0..4] != DEPGRAPH_MAGIC {
435 return Err(SnapshotError::BadMagic);
436 }
437
438 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 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 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#[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 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 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 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_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 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 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()); data.extend_from_slice(&[0u8; 10]); 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 assert_eq!(loaded.stats().context_count, 1);
714 assert_eq!(loaded.get_state(&key), Some(ContextState::Warm));
715
716 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 let removed = graph.trim(Duration::ZERO);
762 assert_eq!(removed, 1);
763 assert_eq!(graph.stats().context_count, 0);
764 }
765
766 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 #[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 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_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 #[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 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 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 #[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 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 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 #[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 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, },
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 save_to_file(&graph, &path1).unwrap();
1049 let loaded1 = load_from_file(&path1).unwrap();
1050
1051 save_to_file(&loaded1, &path2).unwrap();
1053 let loaded2 = load_from_file(&path2).unwrap();
1054
1055 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 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 #[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 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 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 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 #[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 let cold_key = graph.register(make_ctx("/src/cold.cpp"));
1191 assert_eq!(graph.get_state(&cold_key), Some(ContextState::Cold));
1192
1193 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 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 #[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 let mut data = std::fs::read(&path).unwrap();
1262 assert!(data.len() > HEADER_SIZE + 10);
1263 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(_)) => {} Ok(_) => {
1271 }
1275 Err(other) => panic!("unexpected error: {other}"),
1276 }
1277 }
1278
1279 #[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 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 #[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 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 #[test]
1389 fn overwrite_existing_file() {
1390 let dir = TempDir::new().unwrap();
1391 let path = test_path(&dir);
1392
1393 let graph1 = DepGraph::new();
1395 graph1.register(make_ctx("/src/old.cpp"));
1396 save_to_file(&graph1, &path).unwrap();
1397
1398 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 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 #[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()); std::fs::write(&path, &data).unwrap();
1429
1430 match load_from_file(&path) {
1432 Err(SnapshotError::Corrupt(_)) => {}
1433 other => panic!("expected Corrupt for empty payload, got {other:?}"),
1434 }
1435 }
1436
1437 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 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 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 #[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 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 #[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 #[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 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_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 #[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 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 #[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 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 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 #[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 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_to_file(&graph, &path).unwrap();
1883
1884 let mut handles = Vec::new();
1885
1886 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 for _ in 0..3 {
1899 let p = path.clone();
1900 handles.push(std::thread::spawn(move || {
1901 for _ in 0..5 {
1902 let _ = load_from_file(&p);
1904 }
1905 }));
1906 }
1907
1908 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 save_to_file(&graph, &path).unwrap();
1933 let loaded = load_from_file(&path).unwrap();
1934 assert!(loaded.stats().context_count >= 50);
1935 }
1936
1937 #[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]); std::fs::write(&path, &data).unwrap();
1951
1952 assert!(
1954 load_from_file(&path).is_err(),
1955 "u64::MAX payload_len must be rejected"
1956 );
1957 }
1958
1959 #[test]
1962 fn payload_length_overflow_boundary() {
1963 let dir = TempDir::new().unwrap();
1964 let path = test_path(&dir);
1965
1966 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 assert!(
1978 load_from_file(&path).is_err(),
1979 "overflow-inducing payload_len must be rejected"
1980 );
1981 }
1982
1983 #[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 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}