1use std::path::Path;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{Duration, Instant};
10
11use dashmap::DashMap;
12use zccache_core::NormalizedPath;
13use zccache_hash::ContentHash;
14
15use crate::context::{
16 compute_artifact_key, compute_context_key, ArtifactKey, CompileContext, ContextKey,
17};
18use crate::scanner::{IncludeDirective, ScanResult};
19
20#[derive(Debug, Clone)]
22pub struct FileEntry {
23 pub includes: Vec<IncludeDirective>,
25 pub scanned_at: Instant,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ContextState {
32 Cold,
34 Warm,
36 Stale,
38}
39
40#[derive(Debug, Clone)]
42pub struct ContextEntry {
43 pub context: CompileContext,
45 pub key_root: Option<NormalizedPath>,
47 pub resolved_includes: Vec<NormalizedPath>,
49 pub unresolved_includes: Vec<String>,
51 pub has_computed_includes: bool,
53 pub artifact_key: Option<ArtifactKey>,
55 pub last_file_hashes: Vec<(NormalizedPath, ContentHash)>,
57 pub last_accessed: Instant,
59 pub state: ContextState,
61}
62
63#[derive(Debug, Clone)]
65pub enum CacheVerdict {
66 Hit { artifact_key: ArtifactKey },
68 SourceChanged { artifact_key: ArtifactKey },
70 HeadersChanged { changed: Vec<NormalizedPath> },
72 Cold,
74 NeedsPreprocessor,
76}
77
78#[derive(Debug, Clone)]
80pub struct DepGraphStats {
81 pub file_count: usize,
83 pub context_count: usize,
85 pub checks: u64,
87 pub hits: u64,
89 pub misses: u64,
91}
92
93impl std::fmt::Debug for DepGraph {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 f.debug_struct("DepGraph")
97 .field("files", &self.files.len())
98 .field("contexts", &self.contexts.len())
99 .finish()
100 }
101}
102
103pub struct DepGraph {
104 files: DashMap<NormalizedPath, FileEntry>,
106 contexts: DashMap<ContextKey, ContextEntry>,
108 checks: AtomicU64,
110 hits: AtomicU64,
111 misses: AtomicU64,
112}
113
114#[derive(Debug, Clone, Copy)]
115pub struct ContextRegistration {
116 pub key: ContextKey,
117 pub rebased_from_equivalent_root: bool,
118}
119
120fn rebase_project_path(
121 path: &NormalizedPath,
122 old_root: Option<&NormalizedPath>,
123 new_root: Option<&NormalizedPath>,
124) -> NormalizedPath {
125 match (old_root, new_root) {
126 (Some(old_root), Some(new_root)) => path
127 .strip_prefix(old_root)
128 .map(|relative| new_root.join(relative))
129 .unwrap_or_else(|_| path.clone()),
130 _ => path.clone(),
131 }
132}
133
134impl DepGraph {
135 #[must_use]
137 pub fn new() -> Self {
138 Self {
139 files: DashMap::new(),
140 contexts: DashMap::new(),
141 checks: AtomicU64::new(0),
142 hits: AtomicU64::new(0),
143 misses: AtomicU64::new(0),
144 }
145 }
146
147 pub fn register(&self, ctx: CompileContext) -> ContextKey {
150 self.register_with_root(ctx, None)
151 }
152
153 pub fn register_with_root(
156 &self,
157 ctx: CompileContext,
158 key_root: Option<NormalizedPath>,
159 ) -> ContextKey {
160 self.register_with_root_result(ctx, key_root).key
161 }
162
163 pub fn register_with_root_result(
164 &self,
165 ctx: CompileContext,
166 key_root: Option<NormalizedPath>,
167 ) -> ContextRegistration {
168 let key = compute_context_key(&ctx, key_root.as_deref());
169 self.register_with_key_and_root_result(key, ctx, key_root)
170 }
171
172 pub fn register_with_key(&self, key: ContextKey, ctx: CompileContext) -> ContextKey {
178 self.register_with_key_and_root(key, ctx, None)
179 }
180
181 pub fn register_with_key_and_root(
182 &self,
183 key: ContextKey,
184 ctx: CompileContext,
185 key_root: Option<NormalizedPath>,
186 ) -> ContextKey {
187 self.register_with_key_and_root_result(key, ctx, key_root)
188 .key
189 }
190
191 pub fn register_with_key_and_root_result(
192 &self,
193 key: ContextKey,
194 ctx: CompileContext,
195 key_root: Option<NormalizedPath>,
196 ) -> ContextRegistration {
197 let mut rebased_from_equivalent_root = false;
198 self.contexts
199 .entry(key)
200 .and_modify(|entry| {
201 if entry.context.source_file != ctx.source_file || entry.key_root != key_root {
202 let old_root = entry.key_root.clone();
203 rebased_from_equivalent_root =
204 old_root.is_some() && key_root.is_some() && old_root != key_root;
205 entry.resolved_includes = entry
206 .resolved_includes
207 .iter()
208 .map(|path| rebase_project_path(path, old_root.as_ref(), key_root.as_ref()))
209 .collect();
210 entry.last_file_hashes = entry
211 .last_file_hashes
212 .iter()
213 .map(|(path, hash)| {
214 (
215 rebase_project_path(path, old_root.as_ref(), key_root.as_ref()),
216 *hash,
217 )
218 })
219 .collect();
220 entry.context = ctx.clone();
221 entry.key_root = key_root.clone();
222 }
223 entry.last_accessed = Instant::now();
224 })
225 .or_insert_with(|| ContextEntry {
226 context: ctx,
227 key_root,
228 resolved_includes: Vec::new(),
229 unresolved_includes: Vec::new(),
230 has_computed_includes: false,
231 artifact_key: None,
232 last_file_hashes: Vec::new(),
233 last_accessed: Instant::now(),
234 state: ContextState::Cold,
235 });
236
237 ContextRegistration {
238 key,
239 rebased_from_equivalent_root,
240 }
241 }
242
243 #[must_use]
247 pub fn is_cold(&self, key: &ContextKey) -> bool {
248 match self.contexts.get(key) {
249 Some(entry) => entry.state == ContextState::Cold,
250 None => true,
251 }
252 }
253
254 pub fn check<F, G>(&self, key: &ContextKey, is_fresh: F, get_hash: G) -> CacheVerdict
262 where
263 F: Fn(&Path) -> bool,
264 G: Fn(&Path) -> Option<ContentHash>,
265 {
266 self.checks.fetch_add(1, Ordering::Relaxed);
267
268 let mut entry = match self.contexts.get_mut(key) {
269 Some(e) => e,
270 None => {
271 self.misses.fetch_add(1, Ordering::Relaxed);
272 return CacheVerdict::Cold;
273 }
274 };
275
276 entry.last_accessed = Instant::now();
277
278 if entry.state == ContextState::Cold {
279 self.misses.fetch_add(1, Ordering::Relaxed);
280 return CacheVerdict::Cold;
281 }
282
283 if entry.has_computed_includes {
284 self.misses.fetch_add(1, Ordering::Relaxed);
285 return CacheVerdict::NeedsPreprocessor;
286 }
287
288 let fresh_or_hash_match = |path: &NormalizedPath| -> bool {
296 if is_fresh(path) {
297 return true;
298 }
299 let current = match get_hash(path) {
300 Some(h) => h,
301 None => return false,
302 };
303 entry
304 .last_file_hashes
305 .iter()
306 .any(|(p, h)| p == path && *h == current)
307 };
308
309 let source_fresh = fresh_or_hash_match(&entry.context.source_file);
311
312 let mut changed_headers = Vec::new();
314 for header in &entry.resolved_includes {
315 if !fresh_or_hash_match(header) {
316 changed_headers.push(header.clone());
317 }
318 }
319 for fi in &entry.context.force_includes {
321 if !fresh_or_hash_match(fi) {
322 changed_headers.push(fi.clone());
323 }
324 }
325
326 if !changed_headers.is_empty() {
327 self.misses.fetch_add(1, Ordering::Relaxed);
328 entry.state = ContextState::Stale;
329 return CacheVerdict::HeadersChanged {
330 changed: changed_headers,
331 };
332 }
333
334 let mut file_hashes: Vec<(&Path, ContentHash)> = Vec::new();
336
337 if let Some(h) = get_hash(&entry.context.source_file) {
338 file_hashes.push((&entry.context.source_file, h));
339 } else {
340 self.misses.fetch_add(1, Ordering::Relaxed);
341 return CacheVerdict::Cold;
342 }
343
344 for header in &entry.resolved_includes {
345 if let Some(h) = get_hash(header) {
346 file_hashes.push((header, h));
347 } else {
348 self.misses.fetch_add(1, Ordering::Relaxed);
349 return CacheVerdict::Cold;
350 }
351 }
352 for fi in &entry.context.force_includes {
354 if let Some(h) = get_hash(fi) {
355 file_hashes.push((fi, h));
356 } else {
357 self.misses.fetch_add(1, Ordering::Relaxed);
358 return CacheVerdict::Cold;
359 }
360 }
361
362 let artifact_key = compute_artifact_key(key, &mut file_hashes, entry.key_root.as_deref());
363
364 if source_fresh {
365 if entry.artifact_key == Some(artifact_key) {
367 self.hits.fetch_add(1, Ordering::Relaxed);
368 return CacheVerdict::Hit { artifact_key };
369 }
370 entry.artifact_key = Some(artifact_key);
373 self.hits.fetch_add(1, Ordering::Relaxed);
374 CacheVerdict::Hit { artifact_key }
375 } else {
376 entry.artifact_key = Some(artifact_key);
378 self.hits.fetch_add(1, Ordering::Relaxed);
379 CacheVerdict::SourceChanged { artifact_key }
380 }
381 }
382
383 pub fn check_diagnostic<F, G>(
388 &self,
389 key: &ContextKey,
390 is_fresh: F,
391 get_hash: G,
392 ) -> (CacheVerdict, String)
393 where
394 F: Fn(&Path) -> bool,
395 G: Fn(&Path) -> Option<ContentHash>,
396 {
397 self.checks.fetch_add(1, Ordering::Relaxed);
398
399 let mut entry = match self.contexts.get_mut(key) {
400 Some(e) => e,
401 None => {
402 self.misses.fetch_add(1, Ordering::Relaxed);
403 return (CacheVerdict::Cold, "context_key not registered".to_string());
404 }
405 };
406
407 entry.last_accessed = Instant::now();
408
409 if entry.state == ContextState::Cold {
410 self.misses.fetch_add(1, Ordering::Relaxed);
411 return (
412 CacheVerdict::Cold,
413 "context never updated (state=Cold)".to_string(),
414 );
415 }
416
417 if entry.has_computed_includes {
418 self.misses.fetch_add(1, Ordering::Relaxed);
419 return (
420 CacheVerdict::NeedsPreprocessor,
421 "has computed includes, needs preprocessor".to_string(),
422 );
423 }
424
425 let fresh_or_hash_match = |path: &NormalizedPath| -> bool {
429 if is_fresh(path) {
430 return true;
431 }
432 let current = match get_hash(path) {
433 Some(h) => h,
434 None => return false,
435 };
436 entry
437 .last_file_hashes
438 .iter()
439 .any(|(p, h)| p == path && *h == current)
440 };
441
442 let source_fresh = fresh_or_hash_match(&entry.context.source_file);
444
445 let mut changed_headers = Vec::new();
447 for header in &entry.resolved_includes {
448 if !fresh_or_hash_match(header) {
449 changed_headers.push(header.clone());
450 }
451 }
452 for fi in &entry.context.force_includes {
454 if !fresh_or_hash_match(fi) {
455 changed_headers.push(fi.clone());
456 }
457 }
458
459 if !changed_headers.is_empty() {
460 self.misses.fetch_add(1, Ordering::Relaxed);
461 entry.state = ContextState::Stale;
462 let names: Vec<String> = changed_headers
463 .iter()
464 .map(|p| p.display().to_string())
465 .collect();
466 return (
467 CacheVerdict::HeadersChanged {
468 changed: changed_headers,
469 },
470 format!("headers changed: [{}]", names.join(", ")),
471 );
472 }
473
474 let mut file_hashes = Vec::new();
476
477 if let Some(h) = get_hash(&entry.context.source_file) {
478 file_hashes.push((entry.context.source_file.clone(), h));
479 } else {
480 self.misses.fetch_add(1, Ordering::Relaxed);
481 return (
482 CacheVerdict::Cold,
483 format!(
484 "source hash missing: {}",
485 entry.context.source_file.display()
486 ),
487 );
488 }
489
490 for header in &entry.resolved_includes {
491 if let Some(h) = get_hash(header) {
492 file_hashes.push((header.clone(), h));
493 } else {
494 self.misses.fetch_add(1, Ordering::Relaxed);
495 return (
496 CacheVerdict::Cold,
497 format!("header hash missing: {}", header.display()),
498 );
499 }
500 }
501 for fi in &entry.context.force_includes {
503 if let Some(h) = get_hash(fi) {
504 file_hashes.push((fi.clone(), h));
505 } else {
506 self.misses.fetch_add(1, Ordering::Relaxed);
507 return (
508 CacheVerdict::Cold,
509 format!("force-include hash missing: {}", fi.display()),
510 );
511 }
512 }
513
514 let artifact_key = compute_artifact_key(key, &mut file_hashes, entry.key_root.as_deref());
515
516 if source_fresh {
517 if entry.artifact_key == Some(artifact_key) {
518 self.hits.fetch_add(1, Ordering::Relaxed);
519 let hex = &artifact_key.hash().to_hex()[..8];
520 return (
521 CacheVerdict::Hit { artifact_key },
522 format!("hit: artifact_key={hex}"),
523 );
524 }
525 let old_hex = entry
527 .artifact_key
528 .as_ref()
529 .map(|k| k.hash().to_hex()[..8].to_string())
530 .unwrap_or_else(|| "none".to_string());
531
532 let mut drifted: Vec<String> = Vec::new();
534 if !entry.last_file_hashes.is_empty() {
535 let old_map: std::collections::HashMap<&Path, &ContentHash> = entry
536 .last_file_hashes
537 .iter()
538 .map(|(p, h)| (p.as_path(), h))
539 .collect();
540 for (path, new_hash) in &file_hashes {
541 match old_map.get(path.as_path()) {
542 Some(old_hash) if *old_hash != new_hash => {
543 let fname = path
544 .file_name()
545 .map(|n| n.to_string_lossy().to_string())
546 .unwrap_or_else(|| path.display().to_string());
547 drifted.push(fname);
548 }
549 None => {
550 let fname = path
551 .file_name()
552 .map(|n| n.to_string_lossy().to_string())
553 .unwrap_or_else(|| path.display().to_string());
554 drifted.push(format!("{fname}(new)"));
555 }
556 _ => {} }
558 }
559 }
560
561 entry.artifact_key = Some(artifact_key);
562 self.hits.fetch_add(1, Ordering::Relaxed);
563 let hex = &artifact_key.hash().to_hex()[..8];
564 let file_count = file_hashes.len();
565 let drift_info = if drifted.is_empty() {
566 String::new()
567 } else {
568 format!(
569 ", drifted=[{}]",
570 drifted
571 .iter()
572 .take(5)
573 .cloned()
574 .collect::<Vec<_>>()
575 .join(",")
576 )
577 };
578 entry.last_file_hashes = file_hashes;
579 (
580 CacheVerdict::Hit { artifact_key },
581 format!(
582 "hit: artifact_key={hex} (first check after update, was={old_hex}, files={file_count}{drift_info})",
583 ),
584 )
585 } else {
586 entry.artifact_key = Some(artifact_key);
587 self.hits.fetch_add(1, Ordering::Relaxed);
588 (
589 CacheVerdict::SourceChanged { artifact_key },
590 "source content changed".to_string(),
591 )
592 }
593 }
594
595 pub fn try_fast_hit<G>(&self, key: &ContextKey, get_hash: G) -> Option<ArtifactKey>
608 where
609 G: Fn(&Path) -> Option<ContentHash>,
610 {
611 let entry = self.contexts.get(key)?;
612
613 if entry.state == ContextState::Cold || entry.has_computed_includes {
614 return None;
615 }
616
617 let stored_key = entry.artifact_key.as_ref()?;
618
619 let cap = 1 + entry.resolved_includes.len() + entry.context.force_includes.len();
621 let mut file_hashes: Vec<(&Path, ContentHash)> = Vec::with_capacity(cap);
622
623 file_hashes.push((
624 &entry.context.source_file,
625 get_hash(&entry.context.source_file)?,
626 ));
627 for header in &entry.resolved_includes {
628 file_hashes.push((header.as_path(), get_hash(header)?));
629 }
630 for fi in &entry.context.force_includes {
631 file_hashes.push((fi.as_path(), get_hash(fi)?));
632 }
633
634 let computed = compute_artifact_key(key, &mut file_hashes, entry.key_root.as_deref());
635
636 if computed == *stored_key {
637 self.hits.fetch_add(1, Ordering::Relaxed);
638 Some(computed)
639 } else {
640 None
641 }
642 }
643
644 pub fn update<G>(
648 &self,
649 key: &ContextKey,
650 scan_result: ScanResult,
651 get_hash: G,
652 ) -> Option<ArtifactKey>
653 where
654 G: Fn(&Path) -> Option<ContentHash>,
655 {
656 let mut entry = self.contexts.get_mut(key)?;
657
658 entry.resolved_includes = scan_result.resolved;
660 entry.unresolved_includes = scan_result.unresolved;
661 entry.has_computed_includes = scan_result.has_computed;
662 entry.last_accessed = Instant::now();
663 let mut file_hashes = Vec::new();
669 let source_hash = get_hash(&entry.context.source_file)?;
670 file_hashes.push((entry.context.source_file.clone(), source_hash));
671
672 for header in &entry.resolved_includes {
673 match get_hash(header) {
674 Some(h) => file_hashes.push((header.clone(), h)),
675 None => return None, }
677 }
678 for fi in &entry.context.force_includes {
680 match get_hash(fi) {
681 Some(h) => file_hashes.push((fi.clone(), h)),
682 None => return None,
683 }
684 }
685
686 let artifact_key = compute_artifact_key(key, &mut file_hashes, entry.key_root.as_deref());
687
688 entry.state = ContextState::Warm;
690 entry.artifact_key = Some(artifact_key);
691 entry.last_file_hashes = file_hashes;
692
693 Some(artifact_key)
694 }
695
696 pub fn trim(&self, max_age: Duration) -> usize {
699 let now = Instant::now();
700 let mut removed = 0;
701
702 self.contexts.retain(|_, entry| {
703 if now.saturating_duration_since(entry.last_accessed) > max_age {
706 removed += 1;
707 false
708 } else {
709 true
710 }
711 });
712
713 let referenced: std::collections::HashSet<NormalizedPath> = self
715 .contexts
716 .iter()
717 .flat_map(
718 |entry: dashmap::mapref::multiple::RefMulti<'_, ContextKey, ContextEntry>| {
719 let mut paths = entry.value().resolved_includes.clone();
720 paths.push(entry.value().context.source_file.clone());
721 for fi in &entry.value().context.force_includes {
722 paths.push(fi.clone());
723 }
724 paths
725 },
726 )
727 .collect();
728
729 self.files.retain(|path, _| referenced.contains(path));
730
731 removed
732 }
733
734 pub fn clear(&self) {
736 self.files.clear();
737 self.contexts.clear();
738 self.checks.store(0, Ordering::Relaxed);
739 self.hits.store(0, Ordering::Relaxed);
740 self.misses.store(0, Ordering::Relaxed);
741 }
742
743 #[must_use]
745 pub fn stats(&self) -> DepGraphStats {
746 DepGraphStats {
747 file_count: self.files.len(),
748 context_count: self.contexts.len(),
749 checks: self.checks.load(Ordering::Relaxed),
750 hits: self.hits.load(Ordering::Relaxed),
751 misses: self.misses.load(Ordering::Relaxed),
752 }
753 }
754
755 #[must_use]
757 pub fn get_state(&self, key: &ContextKey) -> Option<ContextState> {
758 self.contexts.get(key).map(|e| e.state)
759 }
760
761 #[must_use]
770 pub fn state_breakdown(&self) -> (usize, usize, usize) {
771 let mut cold = 0usize;
772 let mut warm = 0usize;
773 let mut stale = 0usize;
774 for entry in self.contexts.iter() {
775 match entry.value().state {
776 ContextState::Cold => cold += 1,
777 ContextState::Warm => warm += 1,
778 ContextState::Stale => stale += 1,
779 }
780 }
781 (cold, warm, stale)
782 }
783
784 #[must_use]
789 pub fn contexts_with_artifact_key(&self) -> usize {
790 self.contexts
791 .iter()
792 .filter(|e| e.value().artifact_key.is_some())
793 .count()
794 }
795
796 #[must_use]
798 pub fn get_includes(&self, key: &ContextKey) -> Option<Vec<NormalizedPath>> {
799 self.contexts.get(key).map(|e| e.resolved_includes.clone())
800 }
801
802 pub fn store_file_includes(&self, path: NormalizedPath, includes: Vec<IncludeDirective>) {
804 self.files.insert(
805 path,
806 FileEntry {
807 includes,
808 scanned_at: Instant::now(),
809 },
810 );
811 }
812
813 #[must_use]
815 pub fn get_file_includes(&self, path: &NormalizedPath) -> Option<Vec<IncludeDirective>> {
816 self.files.get(path).map(|e| e.includes.clone())
817 }
818
819 pub(crate) fn contexts_iter(&self) -> dashmap::iter::Iter<'_, ContextKey, ContextEntry> {
821 self.contexts.iter()
822 }
823
824 pub(crate) fn files_iter(&self) -> dashmap::iter::Iter<'_, NormalizedPath, FileEntry> {
826 self.files.iter()
827 }
828
829 pub(crate) fn from_maps(
831 files: DashMap<NormalizedPath, FileEntry>,
832 contexts: DashMap<ContextKey, ContextEntry>,
833 ) -> Self {
834 Self {
835 files,
836 contexts,
837 checks: AtomicU64::new(0),
838 hits: AtomicU64::new(0),
839 misses: AtomicU64::new(0),
840 }
841 }
842
843 pub fn mark_stale(&self, key: &ContextKey) -> bool {
846 if let Some(mut entry) = self.contexts.get_mut(key) {
847 entry.state = ContextState::Stale;
848 true
849 } else {
850 false
851 }
852 }
853
854 pub fn ingest_compile_commands(
860 &self,
861 commands: &[crate::compile_commands::CompileCommand],
862 system_includes: &[NormalizedPath],
863 ) -> Vec<ContextKey> {
864 commands
865 .iter()
866 .map(|cmd| {
867 let parsed = cmd.parse();
868 let mut ctx = CompileContext::from_parsed_args(parsed);
869
870 for path in system_includes {
874 if !ctx.include_search.system.contains(path) {
875 ctx.include_search.system.push(path.clone());
876 }
877 }
878
879 self.register(ctx)
880 })
881 .collect()
882 }
883}
884
885impl Default for DepGraph {
886 fn default() -> Self {
887 Self::new()
888 }
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894 use std::path::Path;
895 use zccache_core::NormalizedPath;
896
897 use crate::search_paths::IncludeSearchPaths;
898
899 fn make_ctx(source: &str) -> CompileContext {
900 CompileContext {
901 source_file: NormalizedPath::from(source),
902 include_search: IncludeSearchPaths::default(),
903 defines: Vec::new(),
904 flags: Vec::new(),
905 force_includes: Vec::new(),
906 unknown_flags: Vec::new(),
907 }
908 }
909
910 fn always_fresh(_: &Path) -> bool {
911 true
912 }
913
914 fn never_fresh(_: &Path) -> bool {
915 false
916 }
917
918 fn dummy_hash(path: &Path) -> Option<ContentHash> {
919 Some(zccache_hash::hash_bytes(path.to_string_lossy().as_bytes()))
920 }
921
922 #[test]
923 fn register_returns_consistent_key() {
924 let graph = DepGraph::new();
925 let ctx = make_ctx("/src/a.c");
926 let k1 = graph.register(ctx.clone());
927 let k2 = graph.register(ctx);
928 assert_eq!(k1, k2);
929 }
930
931 #[test]
932 fn cold_context_returns_cold() {
933 let graph = DepGraph::new();
934 let key = graph.register(make_ctx("/src/a.c"));
935 let verdict = graph.check(&key, always_fresh, dummy_hash);
936 assert!(matches!(verdict, CacheVerdict::Cold));
937 }
938
939 #[test]
940 fn unregistered_key_returns_cold() {
941 let graph = DepGraph::new();
942 let ctx = make_ctx("/src/a.c");
943 let key = ctx.context_key();
944 let verdict = graph.check(&key, always_fresh, dummy_hash);
945 assert!(matches!(verdict, CacheVerdict::Cold));
946 }
947
948 #[test]
949 fn warm_context_all_fresh_returns_hit() {
950 let graph = DepGraph::new();
951 let key = graph.register(make_ctx("/src/a.c"));
952
953 let scan = ScanResult {
954 resolved: vec![NormalizedPath::from("/inc/b.h")],
955 unresolved: Vec::new(),
956 has_computed: false,
957 };
958 graph.update(&key, scan, dummy_hash);
959
960 let verdict = graph.check(&key, always_fresh, dummy_hash);
961 assert!(matches!(verdict, CacheVerdict::Hit { .. }));
962 }
963
964 #[test]
965 fn warm_context_source_changed_returns_source_changed() {
966 let graph = DepGraph::new();
967 let key = graph.register(make_ctx("/src/a.c"));
968
969 let scan = ScanResult {
970 resolved: vec![NormalizedPath::from("/inc/b.h")],
971 unresolved: Vec::new(),
972 has_computed: false,
973 };
974 graph.update(&key, scan, dummy_hash);
975
976 let is_fresh = |p: &Path| p != Path::new("/src/a.c");
981 let changed_source_hash = |p: &Path| -> Option<ContentHash> {
982 if p == Path::new("/src/a.c") {
983 Some(zccache_hash::hash_bytes(b"source-modified"))
984 } else {
985 dummy_hash(p)
986 }
987 };
988 let verdict = graph.check(&key, is_fresh, changed_source_hash);
989 assert!(matches!(verdict, CacheVerdict::SourceChanged { .. }));
990 }
991
992 #[test]
993 fn warm_context_header_changed_returns_headers_changed() {
994 let graph = DepGraph::new();
995 let key = graph.register(make_ctx("/src/a.c"));
996
997 let scan = ScanResult {
998 resolved: vec![
999 NormalizedPath::from("/inc/b.h"),
1000 NormalizedPath::from("/inc/c.h"),
1001 ],
1002 unresolved: Vec::new(),
1003 has_computed: false,
1004 };
1005 graph.update(&key, scan, dummy_hash);
1006
1007 let is_fresh = |p: &Path| p != Path::new("/inc/b.h");
1010 let changed_b_hash = |p: &Path| -> Option<ContentHash> {
1011 if p == Path::new("/inc/b.h") {
1012 Some(zccache_hash::hash_bytes(b"b-modified"))
1013 } else {
1014 dummy_hash(p)
1015 }
1016 };
1017 let verdict = graph.check(&key, is_fresh, changed_b_hash);
1018 match verdict {
1019 CacheVerdict::HeadersChanged { changed } => {
1020 assert_eq!(changed, vec![NormalizedPath::from("/inc/b.h")]);
1021 }
1022 other => panic!("expected HeadersChanged, got {other:?}"),
1023 }
1024 }
1025
1026 #[test]
1027 fn warm_context_header_stale_by_watcher_but_hash_unchanged_returns_hit() {
1028 let graph = DepGraph::new();
1036 let key = graph.register(make_ctx("/src/a.c"));
1037
1038 let scan = ScanResult {
1039 resolved: vec![NormalizedPath::from("/inc/b.h")],
1040 unresolved: Vec::new(),
1041 has_computed: false,
1042 };
1043 graph.update(&key, scan, dummy_hash);
1044
1045 let verdict = graph.check(&key, never_fresh, dummy_hash);
1049 assert!(matches!(verdict, CacheVerdict::Hit { .. }));
1050 }
1051
1052 #[test]
1053 fn computed_includes_returns_needs_preprocessor() {
1054 let graph = DepGraph::new();
1055 let key = graph.register(make_ctx("/src/a.c"));
1056
1057 let scan = ScanResult {
1058 resolved: vec![NormalizedPath::from("/inc/b.h")],
1059 unresolved: Vec::new(),
1060 has_computed: true,
1061 };
1062 graph.update(&key, scan, dummy_hash);
1063
1064 let verdict = graph.check(&key, always_fresh, dummy_hash);
1065 assert!(matches!(verdict, CacheVerdict::NeedsPreprocessor));
1066 }
1067
1068 #[test]
1069 fn show_includes_enables_cache_hit_after_computed() {
1070 let graph = DepGraph::new();
1074 let key = graph.register(make_ctx("/src/a.c"));
1075
1076 let scanner_scan = ScanResult {
1078 resolved: vec![NormalizedPath::from("/inc/known.h")],
1079 unresolved: Vec::new(),
1080 has_computed: true,
1081 };
1082 graph.update(&key, scanner_scan, dummy_hash);
1083
1084 let verdict = graph.check(&key, always_fresh, dummy_hash);
1085 assert!(matches!(verdict, CacheVerdict::NeedsPreprocessor));
1086
1087 let depfile_scan = ScanResult {
1089 resolved: vec![
1090 NormalizedPath::from("/inc/known.h"),
1091 NormalizedPath::from("/inc/macro_resolved.h"),
1092 ],
1093 unresolved: Vec::new(),
1094 has_computed: false,
1095 };
1096 graph.update(&key, depfile_scan, dummy_hash);
1097
1098 let verdict = graph.check(&key, always_fresh, dummy_hash);
1100 assert!(
1101 matches!(verdict, CacheVerdict::Hit { .. }),
1102 "expected Hit after /showIncludes update, got {verdict:?}"
1103 );
1104 }
1105
1106 #[test]
1107 fn update_sets_warm_state() {
1108 let graph = DepGraph::new();
1109 let key = graph.register(make_ctx("/src/a.c"));
1110 assert_eq!(graph.get_state(&key), Some(ContextState::Cold));
1111
1112 let scan = ScanResult {
1113 resolved: Vec::new(),
1114 unresolved: Vec::new(),
1115 has_computed: false,
1116 };
1117 graph.update(&key, scan, dummy_hash);
1118 assert_eq!(graph.get_state(&key), Some(ContextState::Warm));
1119 }
1120
1121 #[test]
1122 fn header_change_sets_stale_state() {
1123 let graph = DepGraph::new();
1124 let key = graph.register(make_ctx("/src/a.c"));
1125
1126 let scan = ScanResult {
1127 resolved: vec![NormalizedPath::from("/h.h")],
1128 unresolved: Vec::new(),
1129 has_computed: false,
1130 };
1131 graph.update(&key, scan, dummy_hash);
1132 assert_eq!(graph.get_state(&key), Some(ContextState::Warm));
1133
1134 let changed_h_hash = |p: &Path| -> Option<ContentHash> {
1138 if p == Path::new("/h.h") {
1139 Some(zccache_hash::hash_bytes(b"h-modified"))
1140 } else {
1141 dummy_hash(p)
1142 }
1143 };
1144 graph.check(&key, never_fresh, changed_h_hash);
1145 assert_eq!(graph.get_state(&key), Some(ContextState::Stale));
1146 }
1147
1148 #[test]
1149 fn trim_removes_old_entries() {
1150 let graph = DepGraph::new();
1151 let key = graph.register(make_ctx("/src/a.c"));
1152
1153 let scan = ScanResult {
1154 resolved: Vec::new(),
1155 unresolved: Vec::new(),
1156 has_computed: false,
1157 };
1158 graph.update(&key, scan, dummy_hash);
1159
1160 std::thread::sleep(Duration::from_millis(5));
1162
1163 let removed = graph.trim(Duration::ZERO);
1165 assert_eq!(removed, 1);
1166 assert_eq!(graph.stats().context_count, 0);
1167 }
1168
1169 #[test]
1170 fn trim_keeps_recent_entries() {
1171 let graph = DepGraph::new();
1172 graph.register(make_ctx("/src/a.c"));
1173 let removed = graph.trim(Duration::from_secs(60));
1174 assert_eq!(removed, 0);
1175 assert_eq!(graph.stats().context_count, 1);
1176 }
1177
1178 #[test]
1179 fn stats_track_checks_and_hits() {
1180 let graph = DepGraph::new();
1181 let key = graph.register(make_ctx("/src/a.c"));
1182
1183 let scan = ScanResult {
1184 resolved: Vec::new(),
1185 unresolved: Vec::new(),
1186 has_computed: false,
1187 };
1188 graph.update(&key, scan, dummy_hash);
1189
1190 graph.check(&key, always_fresh, dummy_hash);
1191 graph.check(&key, always_fresh, dummy_hash);
1192
1193 let stats = graph.stats();
1194 assert_eq!(stats.checks, 2);
1195 assert_eq!(stats.hits, 2);
1196 assert_eq!(stats.misses, 0);
1197 assert_eq!(stats.context_count, 1);
1198 }
1199
1200 #[test]
1201 fn artifact_key_changes_when_hash_changes() {
1202 let graph = DepGraph::new();
1203 let key = graph.register(make_ctx("/src/a.c"));
1204
1205 let scan = ScanResult {
1206 resolved: Vec::new(),
1207 unresolved: Vec::new(),
1208 has_computed: false,
1209 };
1210
1211 let hash_v1 = |_: &Path| Some(zccache_hash::hash_bytes(b"v1"));
1212 let ak1 = graph.update(&key, scan.clone(), hash_v1).unwrap();
1213
1214 let hash_v2 = |_: &Path| Some(zccache_hash::hash_bytes(b"v2"));
1215 let ak2 = graph.update(&key, scan, hash_v2).unwrap();
1216
1217 assert_ne!(ak1, ak2);
1218 }
1219
1220 #[test]
1221 fn store_and_get_file_includes() {
1222 let graph = DepGraph::new();
1223 let path = NormalizedPath::from("/src/foo.h");
1224 let includes = vec![crate::IncludeDirective {
1225 kind: crate::IncludeKind::Quoted,
1226 path: "bar.h".to_string(),
1227 line: 1,
1228 }];
1229
1230 graph.store_file_includes(path.clone(), includes.clone());
1231 let retrieved = graph.get_file_includes(&path).unwrap();
1232 assert_eq!(retrieved.len(), 1);
1233 assert_eq!(retrieved[0].path, "bar.h");
1234 }
1235
1236 #[test]
1237 fn concurrent_register_and_check() {
1238 use std::sync::Arc;
1239 use std::thread;
1240
1241 let graph = Arc::new(DepGraph::new());
1242 let mut handles = Vec::new();
1243
1244 for t in 0..4 {
1246 let graph = Arc::clone(&graph);
1247 handles.push(thread::spawn(move || {
1248 for i in 0..50 {
1249 let ctx = make_ctx(&format!("/src/t{t}_f{i}.c"));
1250 let key = graph.register(ctx);
1251
1252 let scan = ScanResult {
1253 resolved: vec![NormalizedPath::from(format!("/inc/t{t}_h{i}.h"))],
1254 unresolved: Vec::new(),
1255 has_computed: false,
1256 };
1257 graph.update(&key, scan, dummy_hash);
1258 graph.check(&key, always_fresh, dummy_hash);
1259 }
1260 }));
1261 }
1262
1263 for h in handles {
1264 h.join().expect("thread panicked");
1265 }
1266
1267 let stats = graph.stats();
1268 assert_eq!(stats.context_count, 200); assert_eq!(stats.checks, 200);
1270 }
1271
1272 #[test]
1273 fn ingest_compile_commands_registers_contexts() {
1274 let json = r#"[
1275 {
1276 "directory": "/build",
1277 "command": "g++ -I/project/include -DNDEBUG -std=c++17 -c /project/src/main.cpp -o main.o",
1278 "file": "/project/src/main.cpp"
1279 },
1280 {
1281 "directory": "/build",
1282 "command": "g++ -I/project/include -DNDEBUG -std=c++17 -c /project/src/util.cpp -o util.o",
1283 "file": "/project/src/util.cpp"
1284 }
1285 ]"#;
1286
1287 let commands = crate::compile_commands::parse_compile_commands_json(json).unwrap();
1288 let graph = DepGraph::new();
1289 let system_includes = vec![NormalizedPath::from("/usr/include")];
1290 let keys = graph.ingest_compile_commands(&commands, &system_includes);
1291
1292 assert_eq!(keys.len(), 2);
1293 assert_eq!(graph.stats().context_count, 2);
1294
1295 for key in &keys {
1297 assert_eq!(graph.get_state(key), Some(ContextState::Cold));
1298 }
1299 }
1300
1301 #[test]
1302 fn ingest_merges_system_includes() {
1303 let json = r#"[
1304 {
1305 "directory": "/build",
1306 "command": "g++ -isystem /explicit/system -c /src/main.cpp",
1307 "file": "/src/main.cpp"
1308 }
1309 ]"#;
1310
1311 let commands = crate::compile_commands::parse_compile_commands_json(json).unwrap();
1312 let graph = DepGraph::new();
1313 let system_includes = vec![NormalizedPath::from("/usr/include")];
1314 let keys = graph.ingest_compile_commands(&commands, &system_includes);
1315
1316 assert_eq!(keys.len(), 1);
1317
1318 let keys_no_sys = graph.ingest_compile_commands(&commands, &[]);
1321
1322 assert_ne!(keys[0], keys_no_sys[0]);
1330 }
1331
1332 #[test]
1333 fn ingest_deduplicates_system_includes() {
1334 let json = r#"[
1335 {
1336 "directory": "/build",
1337 "command": "g++ -isystem /usr/include -c /src/main.cpp",
1338 "file": "/src/main.cpp"
1339 }
1340 ]"#;
1341
1342 let commands = crate::compile_commands::parse_compile_commands_json(json).unwrap();
1343 let graph = DepGraph::new();
1344 let system_includes = vec![NormalizedPath::from("/usr/include")];
1346 let keys = graph.ingest_compile_commands(&commands, &system_includes);
1347 assert_eq!(keys.len(), 1);
1348 }
1349
1350 #[test]
1351 fn clear_resets_everything() {
1352 let graph = DepGraph::new();
1353 let key = graph.register(make_ctx("/src/a.c"));
1354
1355 let scan = ScanResult {
1356 resolved: vec![NormalizedPath::from("/inc/b.h")],
1357 unresolved: Vec::new(),
1358 has_computed: false,
1359 };
1360 graph.update(&key, scan, dummy_hash);
1361 graph.check(&key, always_fresh, dummy_hash);
1362
1363 let stats_before = graph.stats();
1364 assert!(stats_before.context_count > 0);
1365 assert!(stats_before.checks > 0);
1366 assert!(stats_before.hits > 0);
1367
1368 graph.clear();
1369
1370 let stats_after = graph.stats();
1371 assert_eq!(stats_after.context_count, 0);
1372 assert_eq!(stats_after.file_count, 0);
1373 assert_eq!(stats_after.checks, 0);
1374 assert_eq!(stats_after.hits, 0);
1375 assert_eq!(stats_after.misses, 0);
1376 }
1377
1378 #[test]
1379 fn mark_stale_changes_state() {
1380 let graph = DepGraph::new();
1381 let key = graph.register(make_ctx("/src/a.c"));
1382
1383 let scan = ScanResult {
1384 resolved: Vec::new(),
1385 unresolved: Vec::new(),
1386 has_computed: false,
1387 };
1388 graph.update(&key, scan, dummy_hash);
1389 assert_eq!(graph.get_state(&key), Some(ContextState::Warm));
1390
1391 assert!(graph.mark_stale(&key));
1392 assert_eq!(graph.get_state(&key), Some(ContextState::Stale));
1393 }
1394
1395 #[test]
1398 fn update_with_hash_failure_stays_cold() {
1399 let graph = DepGraph::new();
1400 let key = graph.register(make_ctx("/src/a.c"));
1401 assert_eq!(graph.get_state(&key), Some(ContextState::Cold));
1402
1403 let scan = ScanResult {
1404 resolved: vec![NormalizedPath::from("/inc/b.h")],
1405 unresolved: Vec::new(),
1406 has_computed: false,
1407 };
1408 let no_hash = |_: &Path| -> Option<ContentHash> { None };
1410 let result = graph.update(&key, scan, no_hash);
1411 assert!(result.is_none());
1412 assert_eq!(graph.get_state(&key), Some(ContextState::Cold));
1413 }
1414
1415 #[test]
1416 fn update_partial_hash_failure_stays_cold() {
1417 let graph = DepGraph::new();
1418 let key = graph.register(make_ctx("/src/a.c"));
1419
1420 let scan = ScanResult {
1421 resolved: vec![
1422 NormalizedPath::from("/inc/a.h"),
1423 NormalizedPath::from("/inc/b.h"),
1424 NormalizedPath::from("/inc/c.h"),
1425 ],
1426 unresolved: Vec::new(),
1427 has_computed: false,
1428 };
1429 let partial_hash = |p: &Path| -> Option<ContentHash> {
1431 if p == Path::new("/inc/b.h") {
1432 None
1433 } else {
1434 Some(zccache_hash::hash_bytes(p.to_string_lossy().as_bytes()))
1435 }
1436 };
1437 let result = graph.update(&key, scan, partial_hash);
1438 assert!(result.is_none());
1439 assert_eq!(graph.get_state(&key), Some(ContextState::Cold));
1440 }
1441
1442 #[test]
1443 fn update_success_transitions_to_warm() {
1444 let graph = DepGraph::new();
1445 let key = graph.register(make_ctx("/src/a.c"));
1446 assert_eq!(graph.get_state(&key), Some(ContextState::Cold));
1447
1448 let scan = ScanResult {
1449 resolved: vec![NormalizedPath::from("/inc/b.h")],
1450 unresolved: Vec::new(),
1451 has_computed: false,
1452 };
1453 let result = graph.update(&key, scan, dummy_hash);
1454 assert!(result.is_some());
1455 assert_eq!(graph.get_state(&key), Some(ContextState::Warm));
1456 }
1457
1458 #[test]
1459 fn pch_gen_context_hit_after_update() {
1460 let graph = DepGraph::new();
1462 let key = graph.register(make_ctx("/src/pch.h"));
1463
1464 let scan = ScanResult {
1465 resolved: vec![
1466 NormalizedPath::from("/inc/a.h"),
1467 NormalizedPath::from("/inc/b.h"),
1468 ],
1469 unresolved: Vec::new(),
1470 has_computed: false,
1471 };
1472 graph.update(&key, scan, dummy_hash);
1473
1474 let verdict = graph.check(&key, always_fresh, dummy_hash);
1476 assert!(
1477 matches!(verdict, CacheVerdict::Hit { .. }),
1478 "expected Hit after update, got {verdict:?}"
1479 );
1480 }
1481
1482 #[test]
1483 fn warm_context_with_no_artifact_returns_cold_on_check() {
1484 let graph = DepGraph::new();
1488 let ctx = make_ctx("/src/a.c");
1489 let key = ctx.context_key();
1490
1491 graph.contexts.insert(
1493 key,
1494 ContextEntry {
1495 context: ctx,
1496 key_root: None,
1497 resolved_includes: vec![NormalizedPath::from("/inc/b.h")],
1498 unresolved_includes: Vec::new(),
1499 has_computed_includes: false,
1500 artifact_key: None,
1501 last_file_hashes: Vec::new(),
1502 last_accessed: Instant::now(),
1503 state: ContextState::Warm,
1504 },
1505 );
1506
1507 let (verdict, _reason) = graph.check_diagnostic(&key, always_fresh, dummy_hash);
1510 assert!(
1511 matches!(
1512 verdict,
1513 CacheVerdict::Hit { .. } | CacheVerdict::SourceChanged { .. }
1514 ),
1515 "warm context with all hashes available should hit, got {verdict:?}"
1516 );
1517 }
1518
1519 #[test]
1520 fn trim_preserves_force_include_files() {
1521 let graph = DepGraph::new();
1522
1523 let mut ctx = make_ctx("/src/a.c");
1525 ctx.force_includes = vec![NormalizedPath::from("/pch/precompiled.h")];
1526 let key = graph.register(ctx);
1527
1528 let scan = ScanResult {
1529 resolved: vec![NormalizedPath::from("/inc/b.h")],
1530 unresolved: Vec::new(),
1531 has_computed: false,
1532 };
1533 graph.update(&key, scan, dummy_hash);
1534
1535 let empty_includes = vec![crate::IncludeDirective {
1537 kind: crate::IncludeKind::Quoted,
1538 path: "stdafx.h".to_string(),
1539 line: 1,
1540 }];
1541 graph.store_file_includes(
1542 NormalizedPath::from("/pch/precompiled.h"),
1543 empty_includes.clone(),
1544 );
1545 graph.store_file_includes(NormalizedPath::from("/inc/b.h"), empty_includes);
1546
1547 graph.store_file_includes(
1549 NormalizedPath::from("/stale/old.h"),
1550 vec![crate::IncludeDirective {
1551 kind: crate::IncludeKind::Quoted,
1552 path: "gone.h".to_string(),
1553 line: 1,
1554 }],
1555 );
1556
1557 assert_eq!(graph.stats().file_count, 3);
1558
1559 let removed = graph.trim(Duration::from_secs(3600));
1561 assert_eq!(removed, 0);
1562
1563 assert!(
1565 graph
1566 .get_file_includes(&NormalizedPath::from("/pch/precompiled.h"))
1567 .is_some(),
1568 "force-included PCH file should not be evicted by trim"
1569 );
1570 assert!(
1572 graph
1573 .get_file_includes(&NormalizedPath::from("/inc/b.h"))
1574 .is_some(),
1575 "resolved include should not be evicted by trim"
1576 );
1577 assert!(
1579 graph
1580 .get_file_includes(&NormalizedPath::from("/stale/old.h"))
1581 .is_none(),
1582 "unreferenced file should be evicted by trim"
1583 );
1584 assert_eq!(graph.stats().file_count, 2);
1585 }
1586}