Skip to main content

zccache_depgraph/
graph.rs

1//! Core dependency graph.
2//!
3//! Two-map design:
4//! - `files`: shared file nodes (one per unique path, across all contexts)
5//! - `contexts`: per-compilation-context entries with resolved include lists
6
7use 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/// A file node in the graph. Shared across all contexts.
21#[derive(Debug, Clone)]
22pub struct FileEntry {
23    /// Raw `#include` directives found in this file.
24    pub includes: Vec<IncludeDirective>,
25    /// When this file was last scanned for includes.
26    pub scanned_at: Instant,
27}
28
29/// State of a compilation context in the graph.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ContextState {
32    /// No include list yet — needs full recursive scan.
33    Cold,
34    /// Include list populated and believed current.
35    Warm,
36    /// Something changed — needs partial or full rescan.
37    Stale,
38}
39
40/// A compilation context entry in the graph.
41#[derive(Debug, Clone)]
42pub struct ContextEntry {
43    /// The compilation context (source + flags).
44    pub context: CompileContext,
45    /// Optional root used to normalize project-local paths in cache keys.
46    pub key_root: Option<NormalizedPath>,
47    /// Flat list of all transitive resolved headers (absolute paths).
48    pub resolved_includes: Vec<NormalizedPath>,
49    /// Include names that could not be resolved to any file.
50    pub unresolved_includes: Vec<String>,
51    /// True if any `#include MACRO` was found during scanning.
52    pub has_computed_includes: bool,
53    /// Last computed artifact key.
54    pub artifact_key: Option<ArtifactKey>,
55    /// File hashes from the last update() — used for drift diagnostics.
56    pub last_file_hashes: Vec<(NormalizedPath, ContentHash)>,
57    /// When this entry was last accessed (for trimming).
58    pub last_accessed: Instant,
59    /// Current state.
60    pub state: ContextState,
61}
62
63/// Result of checking a context against the file cache.
64#[derive(Debug, Clone)]
65pub enum CacheVerdict {
66    /// All files fresh, artifact key valid. Use cached object.
67    Hit { artifact_key: ArtifactKey },
68    /// Source changed but headers are fresh. New artifact key computed.
69    SourceChanged { artifact_key: ArtifactKey },
70    /// One or more headers changed. Rescan needed.
71    HeadersChanged { changed: Vec<NormalizedPath> },
72    /// No include list yet. Full scan required.
73    Cold,
74    /// Contains `#include MACRO`. Needs preprocessor fallback.
75    NeedsPreprocessor,
76}
77
78/// Statistics about the dependency graph.
79#[derive(Debug, Clone)]
80pub struct DepGraphStats {
81    /// Number of unique files tracked.
82    pub file_count: usize,
83    /// Number of compilation contexts tracked.
84    pub context_count: usize,
85    /// Number of check() calls.
86    pub checks: u64,
87    /// Number of cache hits (ultra-fast + fast path).
88    pub hits: u64,
89    /// Number of cache misses.
90    pub misses: u64,
91}
92
93/// The core dependency graph.
94impl 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    /// Shared file nodes: path → scanned includes.
105    files: DashMap<NormalizedPath, FileEntry>,
106    /// Per-context entries: context key → include list + state.
107    contexts: DashMap<ContextKey, ContextEntry>,
108    /// Stats counters.
109    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    /// Create a new empty dependency graph.
136    #[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    /// Register a compilation context. Returns the context key.
148    /// If the context already exists, returns the existing key.
149    pub fn register(&self, ctx: CompileContext) -> ContextKey {
150        self.register_with_root(ctx, None)
151    }
152
153    /// Register a compilation context with an optional key root used to
154    /// normalize project-local paths across workspace renames.
155    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    /// Register a compilation context with a precomputed key.
173    ///
174    /// Used for Rustc compilations where the context key is computed from
175    /// `RustcCompileContext` (different domain tag) but the dep_graph stores
176    /// a `CompileContext` with the source file path for freshness checks.
177    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    /// Returns `true` if the context has never been updated (no artifact key).
244    /// Used by the server to skip pre-compile hashing on cold contexts where
245    /// `check_diagnostic` would return `Cold` without examining any hashes.
246    #[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    /// Check if a compilation can use cached output.
255    ///
256    /// `is_fresh` is called for each file path. It should query Layer 1
257    /// (fscache) and return `true` if the file has not changed since last
258    /// known state.
259    ///
260    /// `get_hash` retrieves the content hash for a file from Layer 1.
261    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        // Helper: a file is fresh if the journal hasn't seen it change
289        // since `since` OR — when the journal has no opinion (post-restart
290        // cold journal, the watcher dropped events, etc.) — if its current
291        // content hash matches the hash we stored at last `update()`.
292        // The journal is in-memory and starts empty after every daemon
293        // restart; without this fallback, every cached header reports
294        // "changed" and every Warm context degrades to HeadersChanged.
295        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        // Check source file freshness.
310        let source_fresh = fresh_or_hash_match(&entry.context.source_file);
311
312        // Check all headers.
313        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        // Also check force-included files (PCH, -include).
320        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        // All headers fresh. Compute artifact key (using &Path to avoid NormalizedPath clones).
335        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        // Hash force-included files (PCH content must affect artifact key).
353        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            // Ultra-fast path: nothing changed at all.
366            if entry.artifact_key == Some(artifact_key) {
367                self.hits.fetch_add(1, Ordering::Relaxed);
368                return CacheVerdict::Hit { artifact_key };
369            }
370            // Source is "fresh" by watcher but artifact key differs
371            // (could be first check after update).
372            entry.artifact_key = Some(artifact_key);
373            self.hits.fetch_add(1, Ordering::Relaxed);
374            CacheVerdict::Hit { artifact_key }
375        } else {
376            // Fast path: only source changed, headers all fresh.
377            entry.artifact_key = Some(artifact_key);
378            self.hits.fetch_add(1, Ordering::Relaxed);
379            CacheVerdict::SourceChanged { artifact_key }
380        }
381    }
382
383    /// Check if a compilation can use cached output, with diagnostic reason.
384    ///
385    /// Same logic as [`check()`](Self::check) but returns a reason string
386    /// explaining why the verdict was reached (useful for session logs).
387    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        // See `check()` above for the rationale — content-hash fallback
426        // catches the post-restart empty-journal case where every header
427        // would otherwise look "changed".
428        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        // Check source file freshness.
443        let source_fresh = fresh_or_hash_match(&entry.context.source_file);
444
445        // Check all headers.
446        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        // Also check force-included files (PCH, -include).
453        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        // All headers fresh. Compute artifact key.
475        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        // Hash force-included files (PCH content must affect artifact key).
502        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            // Source is "fresh" by watcher but artifact key differs.
526            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            // Find which files have different hashes vs last update().
533            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                        _ => {} // Same hash, no drift
557                    }
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    /// Fast-path artifact key check: recompute the key from caller-provided
596    /// hashes and compare against the stored key.  Returns `Some(key)` when
597    /// they match (common cache-hit case), `None` otherwise.
598    ///
599    /// Compared to `check_diagnostic`, this method:
600    /// - Uses a **shared** DashMap read (no write lock)
601    /// - Skips redundant per-file journal freshness checks (caller already
602    ///   stat-verified every file during the hash phase)
603    /// - Avoids `NormalizedPath` clones by working with references into the entry
604    ///
605    /// Call this *after* hashing and *before* `check_diagnostic`.  On `None`,
606    /// fall back to the full `check_diagnostic` for miss-reason diagnostics.
607    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        // Build file_hashes using references — zero NormalizedPath clones.
620        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    /// After a compile (or on cold path), record the full include list.
645    ///
646    /// `get_hash` retrieves the content hash for a file from Layer 1.
647    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        // Always update include lists (useful for diagnostics even if hashing fails).
659        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        // DO NOT set state=Warm here — wait until all hashes succeed.
664
665        // Compute artifact key — if any file is missing a hash, leave state
666        // unchanged (Cold stays Cold) so check() doesn't see a Warm context
667        // with no artifact key.
668        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, // Incomplete hashes → state stays unchanged
676            }
677        }
678        // Hash force-included files (PCH content must affect artifact key).
679        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        // SUCCESS: all hashes computed — transition to Warm atomically with artifact key.
689        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    /// Trim entries not accessed within the given duration.
697    /// Returns the number of entries removed.
698    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            // Use saturating_duration_since to avoid panic if Instant is
704            // non-monotonic (documented edge case on some platforms/VMs).
705            if now.saturating_duration_since(entry.last_accessed) > max_age {
706                removed += 1;
707                false
708            } else {
709                true
710            }
711        });
712
713        // Also trim file entries not referenced by any context.
714        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    /// Clear all graph state: files, contexts, and stats counters.
735    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    /// Get statistics about the graph.
744    #[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    /// Get the state of a context entry.
756    #[must_use]
757    pub fn get_state(&self, key: &ContextKey) -> Option<ContextState> {
758        self.contexts.get(key).map(|e| e.state)
759    }
760
761    /// Count contexts by state. Returned as `(cold, warm, stale)`.
762    ///
763    /// Used by the daemon's depgraph save / load logging to diagnose
764    /// post-save / post-load state distribution — specifically to find
765    /// out whether contexts are getting persisted as Warm (so `is_cold`
766    /// returns `false` after restore, enabling the cache lookup path)
767    /// or as Cold (so every warm-side compile takes the `cold_skip`
768    /// branch and misses regardless of artifact-store state).
769    #[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    /// Number of contexts whose `artifact_key` is set. Combined with
785    /// `state_breakdown()` this distinguishes contexts that have a
786    /// computed key (a successful prior compile) from contexts that
787    /// were registered but never reached a Warm state.
788    #[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    /// Get the resolved includes for a context.
797    #[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    /// Store scanned includes for a file (shared file node).
803    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    /// Get scanned includes for a file.
814    #[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    /// Iterate over all context entries.
820    pub(crate) fn contexts_iter(&self) -> dashmap::iter::Iter<'_, ContextKey, ContextEntry> {
821        self.contexts.iter()
822    }
823
824    /// Iterate over all file entries.
825    pub(crate) fn files_iter(&self) -> dashmap::iter::Iter<'_, NormalizedPath, FileEntry> {
826        self.files.iter()
827    }
828
829    /// Construct a `DepGraph` from pre-built maps (for deserialization).
830    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    /// Mark a context as stale, requiring rescan on next check.
844    /// Returns `true` if the context existed and was marked stale.
845    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    /// Bulk-populate contexts from parsed compile commands.
855    ///
856    /// For each command, parses the arguments, builds a `CompileContext`
857    /// (merging in the provided system include paths), and registers it.
858    /// Returns the context keys for all successfully registered entries.
859    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                // Merge system includes into the context's search paths.
871                // These go into the `system` field, appended after any
872                // explicit -isystem paths.
873                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        // Source is stale-by-watcher AND its content hash now differs from
977        // the stored hash (post-fallback semantics: a header/source is
978        // only "changed" if journal says stale AND the content hash also
979        // moved).
980        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        // b.h is stale-by-watcher AND its current content hash differs
1008        // from the stored hash (so the hash-fallback also flags it).
1009        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        // Regression guard for the journal-cold-after-restart fix:
1029        // an empty in-memory journal post-restart makes `is_fresh` return
1030        // false for every path, but if the content hash still matches the
1031        // stored one we must treat the file as fresh-by-content. Before
1032        // the fix, every cached header was reported as HeadersChanged and
1033        // every Warm context degraded to a miss on the warm side of the
1034        // cold-tar-untar-warm perf scenario.
1035        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        // Journal claims b.h has changed (it's never been seen), but
1046        // dummy_hash returns the same hash for the same path — so the
1047        // content didn't actually change.
1048        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        // Simulates the MSVC /showIncludes optimization:
1071        // 1. First update from scanner: has_computed=true → NeedsPreprocessor
1072        // 2. Second update from /showIncludes: has_computed=false → Hit
1073        let graph = DepGraph::new();
1074        let key = graph.register(make_ctx("/src/a.c"));
1075
1076        // Scanner found #include MACRO → has_computed=true
1077        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        // /showIncludes resolved all includes → has_computed=false
1088        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        // Now should be a hit.
1099        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        // Both the watcher AND the content hash say h.h changed — the
1135        // hash-fallback can't rescue this one, so the verdict is
1136        // HeadersChanged and the entry flips to Stale.
1137        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        // Sleep briefly so the entry's last_accessed is older than Duration::ZERO.
1161        std::thread::sleep(Duration::from_millis(5));
1162
1163        // Trim with max_age=0: everything not accessed this exact instant is removed.
1164        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        // 4 threads registering and checking.
1245        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); // 4 * 50
1269        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        // All contexts should be Cold (not yet scanned).
1296        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        // The context should have both the explicit and system includes.
1319        // We can verify by checking the context key differs with/without system includes.
1320        let keys_no_sys = graph.ingest_compile_commands(&commands, &[]);
1321
1322        // Same source + different system includes = different context keys.
1323        // Wait, ingest re-uses existing contexts if key matches.
1324        // Since system includes affect the context key, these should differ.
1325        // But we already registered the first one, so let's check differently.
1326        // The first call added /usr/include to system paths, so the key
1327        // incorporates it. A second call with empty system_includes would
1328        // produce a different key.
1329        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        // /usr/include is already in -isystem, should not be added twice.
1345        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    // ── update() atomicity tests ──────────────────────────────────────
1396
1397    #[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        // Source hash fails → update returns None, state must stay Cold.
1409        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        // 2nd header hash fails → state must stay Cold.
1430        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        // Register a PCH-generation context (no force_includes — it IS the PCH).
1461        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        // check() should return Hit, not Cold.
1475        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        // Simulate the bug scenario: state=Warm but artifact_key=None.
1485        // With the fix, this can't happen via update() — but if someone
1486        // manually sets state=Warm, check_diagnostic should handle it.
1487        let graph = DepGraph::new();
1488        let ctx = make_ctx("/src/a.c");
1489        let key = ctx.context_key();
1490
1491        // Manually insert a Warm entry with no artifact key.
1492        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        // check_diagnostic should still produce a valid verdict (not panic).
1508        // With all fresh, it should compute an artifact key and return Hit.
1509        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        // Create a context with a force-include (PCH file).
1524        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        // Populate the files map for both the force-include and resolved include.
1536        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        // Also add an unreferenced file that should be evicted.
1548        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        // Trim with a long max_age — no contexts should be removed.
1560        let removed = graph.trim(Duration::from_secs(3600));
1561        assert_eq!(removed, 0);
1562
1563        // The force-included PCH file must still be in the files map.
1564        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        // Regular includes should also be preserved.
1571        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        // Unreferenced file should be evicted.
1578        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}