1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
// Cache operations for CargoDeadCodeAnalyzer
// Included from cargo_dead_code_analyzer.rs - shares parent module scope
impl CargoDeadCodeAnalyzer {
/// Get the cache file path
///
/// The key has to cover every input that changes the ANSWER, not just the
/// tree hash and the pmat version: two runs that differ only in
/// `--include-tests` (or the traversal depth) analyse different file sets,
/// and they used to share one entry, so the second run replayed the first
/// and the flag looked like a no-op even after the walk started honouring
/// it.
fn cache_path(&self) -> PathBuf {
let included: String = [
(self.exclude_tests, 't'),
(self.exclude_examples, 'e'),
(self.exclude_benches, 'b'),
]
.iter()
.filter(|(excluded, _)| !excluded)
.map(|(_, tag)| *tag)
.collect();
let scope = if included.is_empty() {
"default".to_string()
} else {
included
};
self.project_path.join(".pmat").join(format!(
"dead-code-cache-{scope}-d{}.json",
self.max_depth
))
}
/// Get current git tree hash for cache invalidation
fn get_tree_hash(&self) -> Option<String> {
let output = Command::new("git")
.current_dir(&self.project_path)
.args(["rev-parse", "HEAD:"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
/// Try to load cached result if valid
fn try_load_cache(&self) -> Option<AccurateDeadCodeReport> {
if !self.use_cache || self.force_refresh {
return None;
}
let cache_path = self.cache_path();
let cache_content = std::fs::read_to_string(&cache_path).ok()?;
let cached: CachedDeadCodeResult = serde_json::from_str(&cache_content).ok()?;
// Validate cache
let current_tree_hash = self.get_tree_hash()?;
let current_version = env!("CARGO_PKG_VERSION");
// The SHAPE of the cached report is part of the key. Without it, a
// cache written by an earlier build of the same version was accepted
// whole, and a field added since (`unreachable_items`) came back empty.
if cached.report_schema == DEAD_CODE_CACHE_SCHEMA
&& cached.tree_hash == current_tree_hash
&& cached.pmat_version == current_version
{
tracing::debug!("Dead code cache hit (tree_hash: {})", current_tree_hash);
Some(cached.report)
} else {
tracing::debug!(
"Dead code cache miss (tree: {} vs {}, version: {} vs {})",
cached.tree_hash,
current_tree_hash,
cached.pmat_version,
current_version
);
None
}
}
/// Save result to cache
fn save_cache(&self, report: &AccurateDeadCodeReport) {
if !self.use_cache {
return;
}
let Some(tree_hash) = self.get_tree_hash() else {
return;
};
let cached = CachedDeadCodeResult {
report_schema: DEAD_CODE_CACHE_SCHEMA,
tree_hash,
pmat_version: env!("CARGO_PKG_VERSION").to_string(),
timestamp: chrono::Utc::now(),
report: report.clone(),
};
// Ensure .pmat directory exists
let cache_dir = self.project_path.join(".pmat");
let _ = std::fs::create_dir_all(&cache_dir);
// Write cache file
if let Ok(content) = serde_json::to_string_pretty(&cached) {
let _ = std::fs::write(self.cache_path(), content);
tracing::debug!("Dead code cache saved");
}
}
}
#[cfg(test)]
mod cache_key_tests {
use super::*;
/// Toggling a flag that changes WHICH files are analysed must not hit the
/// entry written by the other configuration — with a shared key, the second
/// `analyze dead-code` run replayed the first and `--include-tests` looked
/// like a no-op even on a correct walk.
#[test]
fn test_cache_key_separates_include_tests_from_the_default() {
let root = std::path::Path::new("/p");
let default_path = CargoDeadCodeAnalyzer::new(root).cache_path();
let with_tests = CargoDeadCodeAnalyzer::new(root).include_tests().cache_path();
// `include_examples()` no longer separates keys, because examples and
// benches are in scope by default — it re-asserts the default rather
// than widening the walk, so there is no second file set to key apart.
assert_ne!(default_path, with_tests);
assert!(default_path.starts_with("/p/.pmat"), "{default_path:?}");
}
/// Depth changes the walk, so it changes the answer too.
#[test]
fn test_cache_key_separates_traversal_depths() {
let root = std::path::Path::new("/p");
assert_ne!(
CargoDeadCodeAnalyzer::new(root).with_max_depth(2).cache_path(),
CargoDeadCodeAnalyzer::new(root).with_max_depth(8).cache_path()
);
}
}