use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use crate::core::NormalizedPath;
use crate::hash::ContentHash;
use dashmap::DashMap;
use super::context::{
compute_artifact_key, compute_context_key, compute_rustc_artifact_key_with_root, ArtifactKey,
CompileContext, ContextKey,
};
use super::scanner::{IncludeDirective, ScanResult};
#[derive(Debug, Clone)]
pub struct FileEntry {
pub includes: Vec<IncludeDirective>,
pub scanned_at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextState {
Cold,
Warm,
Stale,
}
#[derive(Debug, Clone)]
pub struct ContextEntry {
pub context: CompileContext,
pub key_root: Option<NormalizedPath>,
pub resolved_includes: Vec<NormalizedPath>,
pub unresolved_includes: Vec<String>,
pub has_computed_includes: bool,
pub artifact_key: Option<ArtifactKey>,
pub last_file_hashes: Vec<(NormalizedPath, ContentHash)>,
pub last_accessed: Instant,
pub state: ContextState,
}
#[derive(Debug, Clone)]
pub enum CacheVerdict {
Hit { artifact_key: ArtifactKey },
SourceChanged { artifact_key: ArtifactKey },
HeadersChanged { changed: Vec<NormalizedPath> },
Cold,
NeedsPreprocessor,
}
#[derive(Debug, Clone)]
pub struct DepGraphStats {
pub file_count: usize,
pub context_count: usize,
pub checks: u64,
pub hits: u64,
pub misses: u64,
}
impl std::fmt::Debug for DepGraph {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DepGraph")
.field("files", &self.files.len())
.field("contexts", &self.contexts.len())
.finish()
}
}
pub struct DepGraph {
files: DashMap<NormalizedPath, FileEntry>,
contexts: DashMap<ContextKey, ContextEntry>,
rustc_externs: DashMap<ContextKey, Vec<(String, NormalizedPath)>>,
checks: AtomicU64,
hits: AtomicU64,
misses: AtomicU64,
}
#[derive(Debug, Clone, Copy)]
pub struct ContextRegistration {
pub key: ContextKey,
pub rebased_from_equivalent_root: bool,
}
fn rebase_project_path(
path: &NormalizedPath,
old_root: Option<&NormalizedPath>,
new_root: Option<&NormalizedPath>,
) -> NormalizedPath {
match (old_root, new_root) {
(Some(old_root), Some(new_root)) => path
.strip_prefix(old_root)
.map(|relative| new_root.join(relative))
.unwrap_or_else(|_| path.clone()),
_ => path.clone(),
}
}
fn collect_rustc_extern_hashes<G>(
rustc_externs: &[(String, NormalizedPath)],
get_hash: &G,
) -> Option<Vec<(String, ContentHash)>>
where
G: Fn(&Path) -> Option<ContentHash>,
{
let mut extern_hashes = Vec::with_capacity(rustc_externs.len());
for (name, path) in rustc_externs {
extern_hashes.push((name.clone(), get_hash(path)?));
}
Some(extern_hashes)
}
impl DepGraph {
#[must_use]
pub fn new() -> Self {
Self {
files: DashMap::new(),
contexts: DashMap::new(),
rustc_externs: DashMap::new(),
checks: AtomicU64::new(0),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
}
}
pub fn register(&self, ctx: CompileContext) -> ContextKey {
self.register_with_root(ctx, None)
}
pub fn register_with_root(
&self,
ctx: CompileContext,
key_root: Option<NormalizedPath>,
) -> ContextKey {
self.register_with_root_result(ctx, key_root).key
}
pub fn register_with_root_result(
&self,
ctx: CompileContext,
key_root: Option<NormalizedPath>,
) -> ContextRegistration {
let key = compute_context_key(&ctx, key_root.as_deref());
self.register_with_key_and_root_result(key, ctx, key_root)
}
pub fn register_with_key(&self, key: ContextKey, ctx: CompileContext) -> ContextKey {
self.register_with_key_and_root(key, ctx, None)
}
pub fn register_with_key_and_root(
&self,
key: ContextKey,
ctx: CompileContext,
key_root: Option<NormalizedPath>,
) -> ContextKey {
self.register_with_key_and_root_result(key, ctx, key_root)
.key
}
pub fn register_with_key_and_root_result(
&self,
key: ContextKey,
ctx: CompileContext,
key_root: Option<NormalizedPath>,
) -> ContextRegistration {
let registration = self.register_context_entry(key, ctx, key_root);
self.rustc_externs.remove(®istration.key);
registration
}
pub fn register_rustc_with_key_and_root_result(
&self,
key: ContextKey,
ctx: CompileContext,
key_root: Option<NormalizedPath>,
externs: Vec<(String, NormalizedPath)>,
) -> ContextRegistration {
let registration = self.register_context_entry(key, ctx, key_root);
self.rustc_externs.insert(registration.key, externs);
registration
}
fn register_context_entry(
&self,
key: ContextKey,
ctx: CompileContext,
key_root: Option<NormalizedPath>,
) -> ContextRegistration {
let mut rebased_from_equivalent_root = false;
self.contexts
.entry(key)
.and_modify(|entry| {
if entry.context.source_file != ctx.source_file || entry.key_root != key_root {
let old_root = entry.key_root.clone();
rebased_from_equivalent_root =
old_root.is_some() && key_root.is_some() && old_root != key_root;
entry.resolved_includes = entry
.resolved_includes
.iter()
.map(|path| rebase_project_path(path, old_root.as_ref(), key_root.as_ref()))
.collect();
entry.last_file_hashes = entry
.last_file_hashes
.iter()
.map(|(path, hash)| {
(
rebase_project_path(path, old_root.as_ref(), key_root.as_ref()),
*hash,
)
})
.collect();
entry.context = ctx.clone();
entry.key_root = key_root.clone();
}
entry.last_accessed = Instant::now();
})
.or_insert_with(|| ContextEntry {
context: ctx,
key_root,
resolved_includes: Vec::new(),
unresolved_includes: Vec::new(),
has_computed_includes: false,
artifact_key: None,
last_file_hashes: Vec::new(),
last_accessed: Instant::now(),
state: ContextState::Cold,
});
ContextRegistration {
key,
rebased_from_equivalent_root,
}
}
fn rustc_extern_inputs(&self, key: &ContextKey) -> Option<Vec<(String, NormalizedPath)>> {
self.rustc_externs.get(key).map(|externs| externs.clone())
}
#[must_use]
pub fn is_cold(&self, key: &ContextKey) -> bool {
match self.contexts.get(key) {
Some(entry) => entry.state == ContextState::Cold,
None => true,
}
}
pub fn check<F, G>(&self, key: &ContextKey, is_fresh: F, get_hash: G) -> CacheVerdict
where
F: Fn(&Path) -> bool,
G: Fn(&Path) -> Option<ContentHash>,
{
self.checks.fetch_add(1, Ordering::Relaxed);
let rustc_externs = self.rustc_extern_inputs(key);
let mut entry = match self.contexts.get_mut(key) {
Some(e) => e,
None => {
self.misses.fetch_add(1, Ordering::Relaxed);
return CacheVerdict::Cold;
}
};
entry.last_accessed = Instant::now();
if entry.state == ContextState::Cold {
self.misses.fetch_add(1, Ordering::Relaxed);
return CacheVerdict::Cold;
}
if entry.has_computed_includes {
self.misses.fetch_add(1, Ordering::Relaxed);
return CacheVerdict::NeedsPreprocessor;
}
let fresh_or_hash_match = |path: &NormalizedPath| -> bool {
if is_fresh(path) {
return true;
}
let current = match get_hash(path) {
Some(h) => h,
None => return false,
};
entry
.last_file_hashes
.iter()
.any(|(p, h)| p == path && *h == current)
};
let source_fresh = fresh_or_hash_match(&entry.context.source_file);
let mut changed_headers = Vec::new();
for header in &entry.resolved_includes {
if !fresh_or_hash_match(header) {
changed_headers.push(header.clone());
}
}
for fi in &entry.context.force_includes {
if !fresh_or_hash_match(fi) {
changed_headers.push(fi.clone());
}
}
if !changed_headers.is_empty() {
self.misses.fetch_add(1, Ordering::Relaxed);
entry.state = ContextState::Stale;
return CacheVerdict::HeadersChanged {
changed: changed_headers,
};
}
let mut file_hashes: Vec<(&Path, ContentHash)> = Vec::new();
if let Some(h) = get_hash(&entry.context.source_file) {
file_hashes.push((&entry.context.source_file, h));
} else {
self.misses.fetch_add(1, Ordering::Relaxed);
return CacheVerdict::Cold;
}
for header in &entry.resolved_includes {
if let Some(h) = get_hash(header) {
file_hashes.push((header, h));
} else {
self.misses.fetch_add(1, Ordering::Relaxed);
return CacheVerdict::Cold;
}
}
for fi in &entry.context.force_includes {
if let Some(h) = get_hash(fi) {
file_hashes.push((fi, h));
} else {
self.misses.fetch_add(1, Ordering::Relaxed);
return CacheVerdict::Cold;
}
}
let artifact_key = if let Some(externs) = rustc_externs.as_deref() {
let Some(mut extern_hashes) = collect_rustc_extern_hashes(externs, &get_hash) else {
self.misses.fetch_add(1, Ordering::Relaxed);
return CacheVerdict::Cold;
};
compute_rustc_artifact_key_with_root(
key,
&mut file_hashes,
&mut extern_hashes,
entry.key_root.as_deref(),
)
} else {
compute_artifact_key(key, &mut file_hashes, entry.key_root.as_deref())
};
if source_fresh {
if entry.artifact_key == Some(artifact_key) {
self.hits.fetch_add(1, Ordering::Relaxed);
return CacheVerdict::Hit { artifact_key };
}
entry.artifact_key = Some(artifact_key);
self.hits.fetch_add(1, Ordering::Relaxed);
CacheVerdict::Hit { artifact_key }
} else {
entry.artifact_key = Some(artifact_key);
self.hits.fetch_add(1, Ordering::Relaxed);
CacheVerdict::SourceChanged { artifact_key }
}
}
pub fn check_diagnostic<F, G>(
&self,
key: &ContextKey,
is_fresh: F,
get_hash: G,
) -> (CacheVerdict, String)
where
F: Fn(&Path) -> bool,
G: Fn(&Path) -> Option<ContentHash>,
{
self.checks.fetch_add(1, Ordering::Relaxed);
let rustc_externs = self.rustc_extern_inputs(key);
let mut entry = match self.contexts.get_mut(key) {
Some(e) => e,
None => {
self.misses.fetch_add(1, Ordering::Relaxed);
return (CacheVerdict::Cold, "context_key not registered".to_string());
}
};
entry.last_accessed = Instant::now();
if entry.state == ContextState::Cold {
self.misses.fetch_add(1, Ordering::Relaxed);
return (
CacheVerdict::Cold,
"context never updated (state=Cold)".to_string(),
);
}
if entry.has_computed_includes {
self.misses.fetch_add(1, Ordering::Relaxed);
return (
CacheVerdict::NeedsPreprocessor,
"has computed includes, needs preprocessor".to_string(),
);
}
let fresh_or_hash_match = |path: &NormalizedPath| -> bool {
if is_fresh(path) {
return true;
}
let current = match get_hash(path) {
Some(h) => h,
None => return false,
};
entry
.last_file_hashes
.iter()
.any(|(p, h)| p == path && *h == current)
};
let source_fresh = fresh_or_hash_match(&entry.context.source_file);
let mut changed_headers = Vec::new();
for header in &entry.resolved_includes {
if !fresh_or_hash_match(header) {
changed_headers.push(header.clone());
}
}
for fi in &entry.context.force_includes {
if !fresh_or_hash_match(fi) {
changed_headers.push(fi.clone());
}
}
if !changed_headers.is_empty() {
self.misses.fetch_add(1, Ordering::Relaxed);
entry.state = ContextState::Stale;
let names: Vec<String> = changed_headers
.iter()
.map(|p| p.display().to_string())
.collect();
return (
CacheVerdict::HeadersChanged {
changed: changed_headers,
},
format!("headers changed: [{}]", names.join(", ")),
);
}
let mut file_hashes = Vec::new();
if let Some(h) = get_hash(&entry.context.source_file) {
file_hashes.push((entry.context.source_file.clone(), h));
} else {
self.misses.fetch_add(1, Ordering::Relaxed);
return (
CacheVerdict::Cold,
format!(
"source hash missing: {}",
entry.context.source_file.display()
),
);
}
for header in &entry.resolved_includes {
if let Some(h) = get_hash(header) {
file_hashes.push((header.clone(), h));
} else {
self.misses.fetch_add(1, Ordering::Relaxed);
return (
CacheVerdict::Cold,
format!("header hash missing: {}", header.display()),
);
}
}
for fi in &entry.context.force_includes {
if let Some(h) = get_hash(fi) {
file_hashes.push((fi.clone(), h));
} else {
self.misses.fetch_add(1, Ordering::Relaxed);
return (
CacheVerdict::Cold,
format!("force-include hash missing: {}", fi.display()),
);
}
}
let artifact_key = if let Some(externs) = rustc_externs.as_deref() {
let Some(mut extern_hashes) = collect_rustc_extern_hashes(externs, &get_hash) else {
self.misses.fetch_add(1, Ordering::Relaxed);
return (CacheVerdict::Cold, "rustc extern hash missing".to_string());
};
compute_rustc_artifact_key_with_root(
key,
&mut file_hashes,
&mut extern_hashes,
entry.key_root.as_deref(),
)
} else {
compute_artifact_key(key, &mut file_hashes, entry.key_root.as_deref())
};
if source_fresh {
if entry.artifact_key == Some(artifact_key) {
self.hits.fetch_add(1, Ordering::Relaxed);
let hex = &artifact_key.hash().to_hex()[..8];
return (
CacheVerdict::Hit { artifact_key },
format!("hit: artifact_key={hex}"),
);
}
let old_hex = entry
.artifact_key
.as_ref()
.map(|k| k.hash().to_hex()[..8].to_string())
.unwrap_or_else(|| "none".to_string());
let mut drifted: Vec<String> = Vec::new();
if !entry.last_file_hashes.is_empty() {
let old_map: std::collections::HashMap<&Path, &ContentHash> = entry
.last_file_hashes
.iter()
.map(|(p, h)| (p.as_path(), h))
.collect();
for (path, new_hash) in &file_hashes {
match old_map.get(path.as_path()) {
Some(old_hash) if *old_hash != new_hash => {
let fname = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
drifted.push(fname);
}
None => {
let fname = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
drifted.push(format!("{fname}(new)"));
}
_ => {} }
}
}
entry.artifact_key = Some(artifact_key);
self.hits.fetch_add(1, Ordering::Relaxed);
let hex = &artifact_key.hash().to_hex()[..8];
let file_count = file_hashes.len();
let drift_info = if drifted.is_empty() {
String::new()
} else {
format!(
", drifted=[{}]",
drifted
.iter()
.take(5)
.cloned()
.collect::<Vec<_>>()
.join(",")
)
};
entry.last_file_hashes = file_hashes;
(
CacheVerdict::Hit { artifact_key },
format!(
"hit: artifact_key={hex} (first check after update, was={old_hex}, files={file_count}{drift_info})",
),
)
} else {
entry.artifact_key = Some(artifact_key);
self.hits.fetch_add(1, Ordering::Relaxed);
(
CacheVerdict::SourceChanged { artifact_key },
"source content changed".to_string(),
)
}
}
pub fn try_fast_hit<G>(&self, key: &ContextKey, get_hash: G) -> Option<ArtifactKey>
where
G: Fn(&Path) -> Option<ContentHash>,
{
let rustc_externs = self.rustc_extern_inputs(key);
let entry = self.contexts.get(key)?;
if entry.state == ContextState::Cold || entry.has_computed_includes {
return None;
}
let stored_key = entry.artifact_key.as_ref()?;
let cap = 1 + entry.resolved_includes.len() + entry.context.force_includes.len();
let mut file_hashes: Vec<(&Path, ContentHash)> = Vec::with_capacity(cap);
file_hashes.push((
&entry.context.source_file,
get_hash(&entry.context.source_file)?,
));
for header in &entry.resolved_includes {
file_hashes.push((header.as_path(), get_hash(header)?));
}
for fi in &entry.context.force_includes {
file_hashes.push((fi.as_path(), get_hash(fi)?));
}
let computed = if let Some(externs) = rustc_externs.as_deref() {
let mut extern_hashes = collect_rustc_extern_hashes(externs, &get_hash)?;
compute_rustc_artifact_key_with_root(
key,
&mut file_hashes,
&mut extern_hashes,
entry.key_root.as_deref(),
)
} else {
compute_artifact_key(key, &mut file_hashes, entry.key_root.as_deref())
};
if computed == *stored_key {
self.hits.fetch_add(1, Ordering::Relaxed);
Some(computed)
} else {
None
}
}
pub fn update<G>(
&self,
key: &ContextKey,
scan_result: ScanResult,
get_hash: G,
) -> Option<ArtifactKey>
where
G: Fn(&Path) -> Option<ContentHash>,
{
let rustc_externs = self.rustc_extern_inputs(key);
let mut entry = self.contexts.get_mut(key)?;
entry.resolved_includes = scan_result.resolved;
entry.unresolved_includes = scan_result.unresolved;
entry.has_computed_includes = scan_result.has_computed;
entry.last_accessed = Instant::now();
let mut file_hashes = Vec::new();
let source_hash = get_hash(&entry.context.source_file)?;
file_hashes.push((entry.context.source_file.clone(), source_hash));
for header in &entry.resolved_includes {
match get_hash(header) {
Some(h) => file_hashes.push((header.clone(), h)),
None => return None, }
}
for fi in &entry.context.force_includes {
match get_hash(fi) {
Some(h) => file_hashes.push((fi.clone(), h)),
None => return None,
}
}
let artifact_key = if let Some(externs) = rustc_externs.as_deref() {
let mut extern_hashes = collect_rustc_extern_hashes(externs, &get_hash)?;
compute_rustc_artifact_key_with_root(
key,
&mut file_hashes,
&mut extern_hashes,
entry.key_root.as_deref(),
)
} else {
compute_artifact_key(key, &mut file_hashes, entry.key_root.as_deref())
};
entry.state = ContextState::Warm;
entry.artifact_key = Some(artifact_key);
entry.last_file_hashes = file_hashes;
Some(artifact_key)
}
pub fn trim(&self, max_age: Duration) -> usize {
let now = Instant::now();
let mut removed = 0;
self.contexts.retain(|_, entry| {
if now.saturating_duration_since(entry.last_accessed) > max_age {
removed += 1;
false
} else {
true
}
});
self.rustc_externs
.retain(|key, _| self.contexts.contains_key(key));
let referenced: std::collections::HashSet<NormalizedPath> = self
.contexts
.iter()
.flat_map(
|entry: dashmap::mapref::multiple::RefMulti<'_, ContextKey, ContextEntry>| {
let mut paths = entry.value().resolved_includes.clone();
paths.push(entry.value().context.source_file.clone());
for fi in &entry.value().context.force_includes {
paths.push(fi.clone());
}
paths
},
)
.collect();
self.files.retain(|path, _| referenced.contains(path));
removed
}
pub fn clear(&self) {
self.files.clear();
self.contexts.clear();
self.rustc_externs.clear();
self.checks.store(0, Ordering::Relaxed);
self.hits.store(0, Ordering::Relaxed);
self.misses.store(0, Ordering::Relaxed);
}
#[must_use]
pub fn stats(&self) -> DepGraphStats {
DepGraphStats {
file_count: self.files.len(),
context_count: self.contexts.len(),
checks: self.checks.load(Ordering::Relaxed),
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
}
}
#[must_use]
pub fn get_state(&self, key: &ContextKey) -> Option<ContextState> {
self.contexts.get(key).map(|e| e.state)
}
#[must_use]
pub fn state_breakdown(&self) -> (usize, usize, usize) {
let mut cold = 0usize;
let mut warm = 0usize;
let mut stale = 0usize;
for entry in self.contexts.iter() {
match entry.value().state {
ContextState::Cold => cold += 1,
ContextState::Warm => warm += 1,
ContextState::Stale => stale += 1,
}
}
(cold, warm, stale)
}
#[must_use]
pub fn contexts_with_artifact_key(&self) -> usize {
self.contexts
.iter()
.filter(|e| e.value().artifact_key.is_some())
.count()
}
#[must_use]
pub fn get_includes(&self, key: &ContextKey) -> Option<Vec<NormalizedPath>> {
self.contexts.get(key).map(|e| e.resolved_includes.clone())
}
#[must_use]
pub fn get_rustc_externs(&self, key: &ContextKey) -> Option<Vec<(String, NormalizedPath)>> {
self.rustc_extern_inputs(key)
}
pub fn store_file_includes(&self, path: NormalizedPath, includes: Vec<IncludeDirective>) {
self.files.insert(
path,
FileEntry {
includes,
scanned_at: Instant::now(),
},
);
}
#[must_use]
pub fn get_file_includes(&self, path: &NormalizedPath) -> Option<Vec<IncludeDirective>> {
self.files.get(path).map(|e| e.includes.clone())
}
pub(crate) fn contexts_iter(&self) -> dashmap::iter::Iter<'_, ContextKey, ContextEntry> {
self.contexts.iter()
}
pub(crate) fn files_iter(&self) -> dashmap::iter::Iter<'_, NormalizedPath, FileEntry> {
self.files.iter()
}
pub(crate) fn from_maps(
files: DashMap<NormalizedPath, FileEntry>,
contexts: DashMap<ContextKey, ContextEntry>,
) -> Self {
Self {
files,
contexts,
rustc_externs: DashMap::new(),
checks: AtomicU64::new(0),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
}
}
pub fn mark_stale(&self, key: &ContextKey) -> bool {
if let Some(mut entry) = self.contexts.get_mut(key) {
entry.state = ContextState::Stale;
true
} else {
false
}
}
pub fn ingest_compile_commands(
&self,
commands: &[super::compile_commands::CompileCommand],
system_includes: &[NormalizedPath],
) -> Vec<ContextKey> {
commands
.iter()
.map(|cmd| {
let parsed = cmd.parse();
let mut ctx = CompileContext::from_parsed_args(parsed);
for path in system_includes {
if !ctx.include_search.system.contains(path) {
ctx.include_search.system.push(path.clone());
}
}
self.register(ctx)
})
.collect()
}
}
impl Default for DepGraph {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::NormalizedPath;
use std::path::Path;
use super::super::search_paths::IncludeSearchPaths;
fn make_ctx(source: &str) -> CompileContext {
CompileContext {
source_file: NormalizedPath::from(source),
include_search: IncludeSearchPaths::default(),
defines: Vec::new(),
flags: Vec::new(),
force_includes: Vec::new(),
unknown_flags: Vec::new(),
}
}
fn always_fresh(_: &Path) -> bool {
true
}
fn never_fresh(_: &Path) -> bool {
false
}
fn dummy_hash(path: &Path) -> Option<ContentHash> {
Some(crate::hash::hash_bytes(path.to_string_lossy().as_bytes()))
}
#[test]
fn register_returns_consistent_key() {
let graph = DepGraph::new();
let ctx = make_ctx("/src/a.c");
let k1 = graph.register(ctx.clone());
let k2 = graph.register(ctx);
assert_eq!(k1, k2);
}
#[test]
fn cold_context_returns_cold() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let verdict = graph.check(&key, always_fresh, dummy_hash);
assert!(matches!(verdict, CacheVerdict::Cold));
}
#[test]
fn unregistered_key_returns_cold() {
let graph = DepGraph::new();
let ctx = make_ctx("/src/a.c");
let key = ctx.context_key();
let verdict = graph.check(&key, always_fresh, dummy_hash);
assert!(matches!(verdict, CacheVerdict::Cold));
}
#[test]
fn warm_context_all_fresh_returns_hit() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scan = ScanResult {
resolved: vec![NormalizedPath::from("/inc/b.h")],
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
let verdict = graph.check(&key, always_fresh, dummy_hash);
assert!(matches!(verdict, CacheVerdict::Hit { .. }));
}
#[test]
fn rustc_extern_artifact_key_ignores_target_dir_path_shape() {
let graph = DepGraph::new();
let ctx = make_ctx("/src/app.rs");
let key = ctx.context_key();
let source_hash = crate::hash::hash_bytes(b"app");
let extern_hash = crate::hash::hash_bytes(b"dep-v1");
let extern_a = NormalizedPath::from("/target-main/libdep.rlib");
let extern_b = NormalizedPath::from("/target-subagent/libdep.rlib");
graph.register_rustc_with_key_and_root_result(
key,
ctx.clone(),
None,
vec![("dep".to_string(), extern_a.clone())],
);
let first_key = graph
.update(
&key,
ScanResult {
resolved: Vec::new(),
unresolved: Vec::new(),
has_computed: false,
},
|path| {
if path == Path::new("/src/app.rs") {
Some(source_hash)
} else if path == extern_a.as_path() {
Some(extern_hash)
} else {
None
}
},
)
.expect("rustc artifact key should be computed");
graph.register_rustc_with_key_and_root_result(
key,
ctx,
None,
vec![("dep".to_string(), extern_b.clone())],
);
let verdict = graph.check(&key, always_fresh, |path| {
if path == Path::new("/src/app.rs") {
Some(source_hash)
} else if path == extern_b.as_path() {
Some(extern_hash)
} else {
None
}
});
match verdict {
CacheVerdict::Hit { artifact_key } => assert_eq!(artifact_key, first_key),
other => panic!("expected rustc extern path-shape hit, got {other:?}"),
}
}
#[test]
fn warm_context_source_changed_returns_source_changed() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scan = ScanResult {
resolved: vec![NormalizedPath::from("/inc/b.h")],
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
let is_fresh = |p: &Path| p != Path::new("/src/a.c");
let changed_source_hash = |p: &Path| -> Option<ContentHash> {
if p == Path::new("/src/a.c") {
Some(crate::hash::hash_bytes(b"source-modified"))
} else {
dummy_hash(p)
}
};
let verdict = graph.check(&key, is_fresh, changed_source_hash);
assert!(matches!(verdict, CacheVerdict::SourceChanged { .. }));
}
#[test]
fn warm_context_header_changed_returns_headers_changed() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scan = ScanResult {
resolved: vec![
NormalizedPath::from("/inc/b.h"),
NormalizedPath::from("/inc/c.h"),
],
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
let is_fresh = |p: &Path| p != Path::new("/inc/b.h");
let changed_b_hash = |p: &Path| -> Option<ContentHash> {
if p == Path::new("/inc/b.h") {
Some(crate::hash::hash_bytes(b"b-modified"))
} else {
dummy_hash(p)
}
};
let verdict = graph.check(&key, is_fresh, changed_b_hash);
match verdict {
CacheVerdict::HeadersChanged { changed } => {
assert_eq!(changed, vec![NormalizedPath::from("/inc/b.h")]);
}
other => panic!("expected HeadersChanged, got {other:?}"),
}
}
#[test]
fn warm_context_header_stale_by_watcher_but_hash_unchanged_returns_hit() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scan = ScanResult {
resolved: vec![NormalizedPath::from("/inc/b.h")],
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
let verdict = graph.check(&key, never_fresh, dummy_hash);
assert!(matches!(verdict, CacheVerdict::Hit { .. }));
}
#[test]
fn computed_includes_returns_needs_preprocessor() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scan = ScanResult {
resolved: vec![NormalizedPath::from("/inc/b.h")],
unresolved: Vec::new(),
has_computed: true,
};
graph.update(&key, scan, dummy_hash);
let verdict = graph.check(&key, always_fresh, dummy_hash);
assert!(matches!(verdict, CacheVerdict::NeedsPreprocessor));
}
#[test]
fn show_includes_enables_cache_hit_after_computed() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scanner_scan = ScanResult {
resolved: vec![NormalizedPath::from("/inc/known.h")],
unresolved: Vec::new(),
has_computed: true,
};
graph.update(&key, scanner_scan, dummy_hash);
let verdict = graph.check(&key, always_fresh, dummy_hash);
assert!(matches!(verdict, CacheVerdict::NeedsPreprocessor));
let depfile_scan = ScanResult {
resolved: vec![
NormalizedPath::from("/inc/known.h"),
NormalizedPath::from("/inc/macro_resolved.h"),
],
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, depfile_scan, dummy_hash);
let verdict = graph.check(&key, always_fresh, dummy_hash);
assert!(
matches!(verdict, CacheVerdict::Hit { .. }),
"expected Hit after /showIncludes update, got {verdict:?}"
);
}
#[test]
fn update_sets_warm_state() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
assert_eq!(graph.get_state(&key), Some(ContextState::Cold));
let scan = ScanResult {
resolved: Vec::new(),
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
assert_eq!(graph.get_state(&key), Some(ContextState::Warm));
}
#[test]
fn header_change_sets_stale_state() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scan = ScanResult {
resolved: vec![NormalizedPath::from("/h.h")],
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
assert_eq!(graph.get_state(&key), Some(ContextState::Warm));
let changed_h_hash = |p: &Path| -> Option<ContentHash> {
if p == Path::new("/h.h") {
Some(crate::hash::hash_bytes(b"h-modified"))
} else {
dummy_hash(p)
}
};
graph.check(&key, never_fresh, changed_h_hash);
assert_eq!(graph.get_state(&key), Some(ContextState::Stale));
}
#[test]
fn trim_removes_old_entries() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scan = ScanResult {
resolved: Vec::new(),
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
std::thread::sleep(Duration::from_millis(5));
let removed = graph.trim(Duration::ZERO);
assert_eq!(removed, 1);
assert_eq!(graph.stats().context_count, 0);
}
#[test]
fn trim_keeps_recent_entries() {
let graph = DepGraph::new();
graph.register(make_ctx("/src/a.c"));
let removed = graph.trim(Duration::from_secs(60));
assert_eq!(removed, 0);
assert_eq!(graph.stats().context_count, 1);
}
#[test]
fn stats_track_checks_and_hits() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scan = ScanResult {
resolved: Vec::new(),
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
graph.check(&key, always_fresh, dummy_hash);
graph.check(&key, always_fresh, dummy_hash);
let stats = graph.stats();
assert_eq!(stats.checks, 2);
assert_eq!(stats.hits, 2);
assert_eq!(stats.misses, 0);
assert_eq!(stats.context_count, 1);
}
#[test]
fn artifact_key_changes_when_hash_changes() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scan = ScanResult {
resolved: Vec::new(),
unresolved: Vec::new(),
has_computed: false,
};
let hash_v1 = |_: &Path| Some(crate::hash::hash_bytes(b"v1"));
let ak1 = graph.update(&key, scan.clone(), hash_v1).unwrap();
let hash_v2 = |_: &Path| Some(crate::hash::hash_bytes(b"v2"));
let ak2 = graph.update(&key, scan, hash_v2).unwrap();
assert_ne!(ak1, ak2);
}
#[test]
fn store_and_get_file_includes() {
let graph = DepGraph::new();
let path = NormalizedPath::from("/src/foo.h");
let includes = vec![super::super::IncludeDirective {
kind: super::super::IncludeKind::Quoted,
path: "bar.h".to_string(),
line: 1,
}];
graph.store_file_includes(path.clone(), includes.clone());
let retrieved = graph.get_file_includes(&path).unwrap();
assert_eq!(retrieved.len(), 1);
assert_eq!(retrieved[0].path, "bar.h");
}
#[test]
fn concurrent_register_and_check() {
use std::sync::Arc;
use std::thread;
let graph = Arc::new(DepGraph::new());
let mut handles = Vec::new();
for t in 0..4 {
let graph = Arc::clone(&graph);
handles.push(thread::spawn(move || {
for i in 0..50 {
let ctx = make_ctx(&format!("/src/t{t}_f{i}.c"));
let key = graph.register(ctx);
let scan = ScanResult {
resolved: vec![NormalizedPath::from(format!("/inc/t{t}_h{i}.h"))],
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
graph.check(&key, always_fresh, dummy_hash);
}
}));
}
for h in handles {
h.join().expect("thread panicked");
}
let stats = graph.stats();
assert_eq!(stats.context_count, 200); assert_eq!(stats.checks, 200);
}
#[test]
fn ingest_compile_commands_registers_contexts() {
let json = r#"[
{
"directory": "/build",
"command": "g++ -I/project/include -DNDEBUG -std=c++17 -c /project/src/main.cpp -o main.o",
"file": "/project/src/main.cpp"
},
{
"directory": "/build",
"command": "g++ -I/project/include -DNDEBUG -std=c++17 -c /project/src/util.cpp -o util.o",
"file": "/project/src/util.cpp"
}
]"#;
let commands = super::super::compile_commands::parse_compile_commands_json(json).unwrap();
let graph = DepGraph::new();
let system_includes = vec![NormalizedPath::from("/usr/include")];
let keys = graph.ingest_compile_commands(&commands, &system_includes);
assert_eq!(keys.len(), 2);
assert_eq!(graph.stats().context_count, 2);
for key in &keys {
assert_eq!(graph.get_state(key), Some(ContextState::Cold));
}
}
#[test]
fn ingest_merges_system_includes() {
let json = r#"[
{
"directory": "/build",
"command": "g++ -isystem /explicit/system -c /src/main.cpp",
"file": "/src/main.cpp"
}
]"#;
let commands = super::super::compile_commands::parse_compile_commands_json(json).unwrap();
let graph = DepGraph::new();
let system_includes = vec![NormalizedPath::from("/usr/include")];
let keys = graph.ingest_compile_commands(&commands, &system_includes);
assert_eq!(keys.len(), 1);
let keys_no_sys = graph.ingest_compile_commands(&commands, &[]);
assert_ne!(keys[0], keys_no_sys[0]);
}
#[test]
fn ingest_deduplicates_system_includes() {
let json = r#"[
{
"directory": "/build",
"command": "g++ -isystem /usr/include -c /src/main.cpp",
"file": "/src/main.cpp"
}
]"#;
let commands = super::super::compile_commands::parse_compile_commands_json(json).unwrap();
let graph = DepGraph::new();
let system_includes = vec![NormalizedPath::from("/usr/include")];
let keys = graph.ingest_compile_commands(&commands, &system_includes);
assert_eq!(keys.len(), 1);
}
#[test]
fn clear_resets_everything() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scan = ScanResult {
resolved: vec![NormalizedPath::from("/inc/b.h")],
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
graph.check(&key, always_fresh, dummy_hash);
let stats_before = graph.stats();
assert!(stats_before.context_count > 0);
assert!(stats_before.checks > 0);
assert!(stats_before.hits > 0);
graph.clear();
let stats_after = graph.stats();
assert_eq!(stats_after.context_count, 0);
assert_eq!(stats_after.file_count, 0);
assert_eq!(stats_after.checks, 0);
assert_eq!(stats_after.hits, 0);
assert_eq!(stats_after.misses, 0);
}
#[test]
fn mark_stale_changes_state() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scan = ScanResult {
resolved: Vec::new(),
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
assert_eq!(graph.get_state(&key), Some(ContextState::Warm));
assert!(graph.mark_stale(&key));
assert_eq!(graph.get_state(&key), Some(ContextState::Stale));
}
#[test]
fn update_with_hash_failure_stays_cold() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
assert_eq!(graph.get_state(&key), Some(ContextState::Cold));
let scan = ScanResult {
resolved: vec![NormalizedPath::from("/inc/b.h")],
unresolved: Vec::new(),
has_computed: false,
};
let no_hash = |_: &Path| -> Option<ContentHash> { None };
let result = graph.update(&key, scan, no_hash);
assert!(result.is_none());
assert_eq!(graph.get_state(&key), Some(ContextState::Cold));
}
#[test]
fn update_partial_hash_failure_stays_cold() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
let scan = ScanResult {
resolved: vec![
NormalizedPath::from("/inc/a.h"),
NormalizedPath::from("/inc/b.h"),
NormalizedPath::from("/inc/c.h"),
],
unresolved: Vec::new(),
has_computed: false,
};
let partial_hash = |p: &Path| -> Option<ContentHash> {
if p == Path::new("/inc/b.h") {
None
} else {
Some(crate::hash::hash_bytes(p.to_string_lossy().as_bytes()))
}
};
let result = graph.update(&key, scan, partial_hash);
assert!(result.is_none());
assert_eq!(graph.get_state(&key), Some(ContextState::Cold));
}
#[test]
fn update_success_transitions_to_warm() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/a.c"));
assert_eq!(graph.get_state(&key), Some(ContextState::Cold));
let scan = ScanResult {
resolved: vec![NormalizedPath::from("/inc/b.h")],
unresolved: Vec::new(),
has_computed: false,
};
let result = graph.update(&key, scan, dummy_hash);
assert!(result.is_some());
assert_eq!(graph.get_state(&key), Some(ContextState::Warm));
}
#[test]
fn pch_gen_context_hit_after_update() {
let graph = DepGraph::new();
let key = graph.register(make_ctx("/src/pch.h"));
let scan = ScanResult {
resolved: vec![
NormalizedPath::from("/inc/a.h"),
NormalizedPath::from("/inc/b.h"),
],
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
let verdict = graph.check(&key, always_fresh, dummy_hash);
assert!(
matches!(verdict, CacheVerdict::Hit { .. }),
"expected Hit after update, got {verdict:?}"
);
}
#[test]
fn warm_context_with_no_artifact_returns_cold_on_check() {
let graph = DepGraph::new();
let ctx = make_ctx("/src/a.c");
let key = ctx.context_key();
graph.contexts.insert(
key,
ContextEntry {
context: ctx,
key_root: None,
resolved_includes: vec![NormalizedPath::from("/inc/b.h")],
unresolved_includes: Vec::new(),
has_computed_includes: false,
artifact_key: None,
last_file_hashes: Vec::new(),
last_accessed: Instant::now(),
state: ContextState::Warm,
},
);
let (verdict, _reason) = graph.check_diagnostic(&key, always_fresh, dummy_hash);
assert!(
matches!(
verdict,
CacheVerdict::Hit { .. } | CacheVerdict::SourceChanged { .. }
),
"warm context with all hashes available should hit, got {verdict:?}"
);
}
#[test]
fn trim_preserves_force_include_files() {
let graph = DepGraph::new();
let mut ctx = make_ctx("/src/a.c");
ctx.force_includes = vec![NormalizedPath::from("/pch/precompiled.h")];
let key = graph.register(ctx);
let scan = ScanResult {
resolved: vec![NormalizedPath::from("/inc/b.h")],
unresolved: Vec::new(),
has_computed: false,
};
graph.update(&key, scan, dummy_hash);
let empty_includes = vec![super::super::IncludeDirective {
kind: super::super::IncludeKind::Quoted,
path: "stdafx.h".to_string(),
line: 1,
}];
graph.store_file_includes(
NormalizedPath::from("/pch/precompiled.h"),
empty_includes.clone(),
);
graph.store_file_includes(NormalizedPath::from("/inc/b.h"), empty_includes);
graph.store_file_includes(
NormalizedPath::from("/stale/old.h"),
vec![super::super::IncludeDirective {
kind: super::super::IncludeKind::Quoted,
path: "gone.h".to_string(),
line: 1,
}],
);
assert_eq!(graph.stats().file_count, 3);
let removed = graph.trim(Duration::from_secs(3600));
assert_eq!(removed, 0);
assert!(
graph
.get_file_includes(&NormalizedPath::from("/pch/precompiled.h"))
.is_some(),
"force-included PCH file should not be evicted by trim"
);
assert!(
graph
.get_file_includes(&NormalizedPath::from("/inc/b.h"))
.is_some(),
"resolved include should not be evicted by trim"
);
assert!(
graph
.get_file_includes(&NormalizedPath::from("/stale/old.h"))
.is_none(),
"unreferenced file should be evicted by trim"
);
assert_eq!(graph.stats().file_count, 2);
}
}