1use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use super::types::{Finding, HuntMode};
10
11#[derive(Debug, serde::Serialize, serde::Deserialize)]
13pub struct CachedFindings {
14 pub findings: Vec<Finding>,
15 pub mode: HuntMode,
16 pub config_hash: String,
17}
18
19pub fn cache_key(project_path: &Path, config: &super::types::HuntConfig) -> String {
23 let mut hash: u64 = 0xcbf29ce484222325; let prime: u64 = 0x00000100000001B3;
25
26 let input = format!(
27 "{}:{}:{:?}:{:.4}:{}:{}:{}:{}:{}",
28 project_path.display(),
29 config.mode,
30 config.targets,
31 config.min_suspiciousness,
32 config.use_pmat_quality,
33 config.contracts_auto,
34 config.contracts_path.as_deref().map(|p| p.display().to_string()).unwrap_or_default(),
35 config.model_parity_auto,
36 config.model_parity_path.as_deref().map(|p| p.display().to_string()).unwrap_or_default(),
37 );
38
39 for byte in input.bytes() {
40 hash ^= byte as u64;
41 hash = hash.wrapping_mul(prime);
42 }
43
44 format!("{:016x}", hash)
45}
46
47pub fn any_source_newer_than(project_path: &Path, cache_mtime: SystemTime) -> bool {
49 let pattern = format!("{}/**/*.rs", project_path.display());
50 if let Ok(entries) = glob::glob(&pattern) {
51 for entry in entries.flatten() {
52 if let Ok(meta) = entry.metadata() {
53 if let Ok(mtime) = meta.modified() {
54 if mtime > cache_mtime {
55 return true;
56 }
57 }
58 }
59 }
60 }
61 false
62}
63
64fn cache_dir(project_path: &Path) -> PathBuf {
66 project_path.join(".pmat").join("bug-hunter-cache")
67}
68
69pub fn load_cached(
74 project_path: &Path,
75 config: &super::types::HuntConfig,
76) -> Option<CachedFindings> {
77 let key = cache_key(project_path, config);
78 let cache_file = cache_dir(project_path).join(format!("{}.json", key));
79
80 if !cache_file.exists() {
81 return None;
82 }
83
84 let cache_mtime = cache_file.metadata().ok()?.modified().ok()?;
85
86 if any_source_newer_than(project_path, cache_mtime) {
87 return None;
88 }
89
90 let content = std::fs::read_to_string(&cache_file).ok()?;
91 let cached: CachedFindings = serde_json::from_str(&content).ok()?;
92
93 if cached.config_hash != key {
95 return None;
96 }
97
98 Some(cached)
99}
100
101pub fn save_cache(
103 project_path: &Path,
104 config: &super::types::HuntConfig,
105 findings: &[Finding],
106 mode: HuntMode,
107) {
108 let key = cache_key(project_path, config);
109 let dir = cache_dir(project_path);
110
111 if std::fs::create_dir_all(&dir).is_err() {
112 return;
113 }
114
115 let cached = CachedFindings { findings: findings.to_vec(), mode, config_hash: key.clone() };
116
117 let cache_file = dir.join(format!("{}.json", key));
118 if let Ok(json) = serde_json::to_string(&cached) {
119 let _ = std::fs::write(&cache_file, json);
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::bug_hunter::types::HuntConfig;
127
128 #[test]
129 fn test_cache_key_deterministic() {
130 let config = HuntConfig::default();
131 let k1 = cache_key(Path::new("/tmp/proj"), &config);
132 let k2 = cache_key(Path::new("/tmp/proj"), &config);
133 assert_eq!(k1, k2, "Same inputs must produce same key");
134 }
135
136 #[test]
137 fn test_cache_key_varies() {
138 let c1 = HuntConfig::default();
139 let c2 = HuntConfig { mode: HuntMode::Quick, ..Default::default() };
140
141 let k1 = cache_key(Path::new("/tmp/proj"), &c1);
142 let k2 = cache_key(Path::new("/tmp/proj"), &c2);
143 assert_ne!(k1, k2, "Different modes must produce different keys");
144
145 let k3 = cache_key(Path::new("/tmp/other"), &c1);
146 assert_ne!(k1, k3, "Different paths must produce different keys");
147 }
148
149 #[test]
150 fn test_load_cached_empty_dir() {
151 let temp = std::env::temp_dir().join("test_bh_cache_empty");
152 let _ = std::fs::create_dir_all(&temp);
153 let config = HuntConfig::default();
154
155 let result = load_cached(&temp, &config);
156 assert!(result.is_none(), "Empty dir should return None");
157
158 let _ = std::fs::remove_dir_all(&temp);
159 }
160
161 #[test]
162 fn test_save_and_load_roundtrip() {
163 let temp = std::env::temp_dir().join("test_bh_cache_roundtrip");
164 let _ = std::fs::remove_dir_all(&temp);
165 let _ = std::fs::create_dir_all(&temp);
166
167 let config = HuntConfig { mode: HuntMode::Quick, ..Default::default() };
168
169 let findings = vec![Finding::new("BH-001", "src/lib.rs", 42, "Test finding")];
170
171 save_cache(&temp, &config, &findings, HuntMode::Quick);
172
173 let cached = load_cached(&temp, &config);
174 assert!(cached.is_some(), "Should load cached findings");
175
176 let cached = cached.expect("unexpected failure");
177 assert_eq!(cached.findings.len(), 1);
178 assert_eq!(cached.findings[0].id, "BH-001");
179 assert_eq!(cached.mode, HuntMode::Quick);
180
181 let _ = std::fs::remove_dir_all(&temp);
182 }
183
184 #[test]
185 fn test_any_source_newer_than_returns_true() {
186 let temp = std::env::temp_dir().join("test_bh_cache_newer");
188 let _ = std::fs::remove_dir_all(&temp);
189 let _ = std::fs::create_dir_all(&temp);
190 std::fs::write(temp.join("test.rs"), "fn main() {}").expect("write");
191
192 let old_time = std::time::UNIX_EPOCH;
194 assert!(
195 any_source_newer_than(&temp, old_time),
196 "Source file should be newer than UNIX_EPOCH"
197 );
198
199 let _ = std::fs::remove_dir_all(&temp);
200 }
201
202 #[test]
203 fn test_load_cached_invalidated_by_newer_source() {
204 let temp = std::env::temp_dir().join("test_bh_cache_invalidate");
206 let _ = std::fs::remove_dir_all(&temp);
207 let _ = std::fs::create_dir_all(&temp);
208
209 let config = HuntConfig { mode: HuntMode::Quick, ..Default::default() };
210
211 let findings = vec![Finding::new("BH-002", "src/lib.rs", 1, "Finding")];
212 save_cache(&temp, &config, &findings, HuntMode::Quick);
213
214 std::thread::sleep(std::time::Duration::from_millis(50));
216 std::fs::write(temp.join("new_file.rs"), "// new").expect("write");
217
218 let cached = load_cached(&temp, &config);
219 assert!(cached.is_none(), "Cache should be invalidated by newer source");
220
221 let _ = std::fs::remove_dir_all(&temp);
222 }
223
224 #[test]
225 fn test_cache_key_varies_with_contracts_auto() {
226 let c1 = HuntConfig::default();
227 let c2 = HuntConfig { contracts_auto: true, ..Default::default() };
228
229 let k1 = cache_key(Path::new("/tmp/proj"), &c1);
230 let k2 = cache_key(Path::new("/tmp/proj"), &c2);
231 assert_ne!(k1, k2, "contracts_auto must change cache key");
232 }
233
234 #[test]
235 fn test_cache_key_varies_with_model_parity_auto() {
236 let c1 = HuntConfig::default();
237 let c2 = HuntConfig { model_parity_auto: true, ..Default::default() };
238
239 let k1 = cache_key(Path::new("/tmp/proj"), &c1);
240 let k2 = cache_key(Path::new("/tmp/proj"), &c2);
241 assert_ne!(k1, k2, "model_parity_auto must change cache key");
242 }
243
244 #[test]
245 fn test_cache_key_varies_with_contracts_path() {
246 let c1 = HuntConfig::default();
247 let c2 = HuntConfig {
248 contracts_path: Some(PathBuf::from("/tmp/contracts")),
249 ..Default::default()
250 };
251
252 let k1 = cache_key(Path::new("/tmp/proj"), &c1);
253 let k2 = cache_key(Path::new("/tmp/proj"), &c2);
254 assert_ne!(k1, k2, "contracts_path must change cache key");
255 }
256
257 #[test]
258 fn test_load_cached_config_hash_mismatch() {
259 let temp = std::env::temp_dir().join("test_bh_cache_hash_mismatch");
261 let _ = std::fs::remove_dir_all(&temp);
262 let _ = std::fs::create_dir_all(&temp);
263
264 let config = HuntConfig { mode: HuntMode::Quick, ..Default::default() };
265
266 let key = cache_key(&temp, &config);
267 let dir = temp.join(".pmat").join("bug-hunter-cache");
268 let _ = std::fs::create_dir_all(&dir);
269
270 let cached = CachedFindings {
271 findings: vec![],
272 mode: HuntMode::Quick,
273 config_hash: "wrong_hash_value".to_string(),
274 };
275 let cache_file = dir.join(format!("{}.json", key));
276 let json = serde_json::to_string(&cached).expect("serialize");
277 std::fs::write(&cache_file, json).expect("write");
278
279 let result = load_cached(&temp, &config);
280 assert!(result.is_none(), "Should return None on config hash mismatch");
281
282 let _ = std::fs::remove_dir_all(&temp);
283 }
284}