1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use thiserror::Error;
8
9use crate::cross_file::FileSymbols;
10use crate::finding::Finding;
11use crate::resolution::FileImports;
12use crate::rule::Rule;
13
14const RULES_HASH_FILE: &str = "rules.hash";
15const CACHE_GITIGNORE: &str = "*\n";
16
17const CACHE_SCHEMA_VERSION: &str = "v3";
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct CacheEntry {
28 pub findings: Vec<Finding>,
29 pub symbols: FileSymbols,
30 #[serde(default)]
31 pub imports: FileImports,
32}
33
34pub fn sha256_hex(data: &[u8]) -> String {
36 let mut hasher = Sha256::new();
37 hasher.update(data);
38 format!("{:x}", hasher.finalize())
39}
40
41pub fn ensure_gitignored_dir(dir: &Path) -> Result<(), io::Error> {
48 if !dir.exists() {
49 fs::create_dir_all(dir)?;
50 fs::write(dir.join(".gitignore"), CACHE_GITIGNORE)?;
51 }
52 Ok(())
53}
54
55#[derive(Debug, Error)]
56pub enum CacheError {
57 #[error("cache I/O error: {0}")]
58 Io(#[from] io::Error),
59}
60
61pub fn rules_hash(rules: &[Rule]) -> String {
70 let mut sorted: Vec<&Rule> = rules.iter().collect();
71 sorted.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
72
73 let mut hasher = Sha256::new();
74 hasher.update(CACHE_SCHEMA_VERSION.as_bytes());
75 hasher.update(b"\0");
76 hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
77 hasher.update(b"\0");
78 for rule in &sorted {
79 hasher.update(rule.id.as_str().as_bytes());
80 hasher.update(b"\0");
81 if let Ok(body) = serde_json::to_vec(rule) {
82 hasher.update(&body);
83 }
84 hasher.update(b"\0");
85 }
86 format!("{:x}", hasher.finalize())
87}
88
89pub fn file_content_hash(content: &[u8]) -> String {
93 sha256_hex(content)
94}
95
96pub struct CacheStore {
98 dir: PathBuf,
99 key_prefix: String,
103}
104
105impl CacheStore {
106 pub fn new(project_root: &Path) -> Self {
107 Self {
108 dir: project_root.join(".slopguard-cache"),
109 key_prefix: String::new(),
110 }
111 }
112
113 pub fn with_dir(dir: PathBuf) -> Self {
116 Self {
117 dir,
118 key_prefix: String::new(),
119 }
120 }
121
122 pub fn scoped_to_rules(mut self, rules_hash: &str) -> Self {
127 self.key_prefix = format!("{rules_hash}-");
128 self
129 }
130
131 pub fn dir(&self) -> &Path {
132 &self.dir
133 }
134
135 fn ensure_dir(&self) -> Result<(), CacheError> {
136 ensure_gitignored_dir(&self.dir)?;
137 Ok(())
138 }
139
140 fn stored_rules_hash(&self) -> Option<String> {
141 fs::read_to_string(self.dir.join(RULES_HASH_FILE)).ok()
142 }
143
144 fn write_rules_hash(&self, hash: &str) -> Result<(), CacheError> {
145 self.ensure_dir()?;
146 fs::write(self.dir.join(RULES_HASH_FILE), hash)?;
147 Ok(())
148 }
149
150 pub fn check_rules_changed(&self, current_hash: &str) -> Result<bool, CacheError> {
153 match self.stored_rules_hash() {
154 Some(stored) if stored == current_hash => Ok(false),
155 _ => {
156 self.invalidate_all()?;
157 self.write_rules_hash(current_hash)?;
158 Ok(true)
159 }
160 }
161 }
162
163 fn invalidate_all(&self) -> Result<(), CacheError> {
164 if self.dir.exists() {
165 for entry in fs::read_dir(&self.dir)? {
166 let entry = entry?;
167 let name = entry.file_name();
168 let name_str = name.to_string_lossy();
169 if name_str != ".gitignore" && name_str != RULES_HASH_FILE {
170 fs::remove_file(entry.path())?;
171 }
172 }
173 }
174 Ok(())
175 }
176
177 fn entry_path(&self, file_hash: &str) -> PathBuf {
178 self.dir.join(format!("{}{file_hash}.bin", self.key_prefix))
179 }
180
181 pub fn get(&self, file_hash: &str) -> Option<CacheEntry> {
183 let path = self.entry_path(file_hash);
184 let data = fs::read(&path).ok()?;
185 rmp_serde::from_slice(&data).ok()
186 }
187
188 pub fn put(&self, file_hash: &str, entry: &CacheEntry) -> Result<(), CacheError> {
190 self.ensure_dir()?;
191 let data = rmp_serde::to_vec_named(entry).map_err(io::Error::other)?;
192 fs::write(self.entry_path(file_hash), data)?;
193 Ok(())
194 }
195
196 pub fn cleanup(&self, current_hashes: &[String]) -> Result<usize, CacheError> {
199 if !self.dir.exists() {
200 return Ok(0);
201 }
202 let mut removed = 0;
203 for entry in fs::read_dir(&self.dir)? {
204 let entry = entry?;
205 let name = entry.file_name();
206 let name_str = name.to_string_lossy();
207 if name_str == ".gitignore" || name_str == RULES_HASH_FILE {
208 continue;
209 }
210 let Some(rest) = name_str.strip_prefix(&self.key_prefix) else {
213 continue;
214 };
215 let hash = rest.trim_end_matches(".bin");
216 if !current_hashes.iter().any(|h| h == hash) {
217 fs::remove_file(entry.path())?;
218 removed += 1;
219 }
220 }
221 Ok(removed)
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use std::path::PathBuf;
228 use std::slice;
229
230 use tempfile::tempdir;
231
232 use super::*;
233 use crate::cross_file::{TraitDecl, TraitImpl};
234 use crate::resolution::ImportRef;
235 use crate::rule::{parse_rule, RuleId, Severity};
236
237 fn test_rule() -> Rule {
238 parse_rule(
239 r#"
240id: test-unwrap
241language: rust
242severity: error
243category: correctness
244message: ".unwrap() forbidden"
245rule:
246 kind: call_expression
247 regex: '\.unwrap\(\)\s*$'
248"#,
249 )
250 .unwrap()
251 }
252
253 fn sample_finding() -> Finding {
254 Finding {
255 rule_id: RuleId::from("test-unwrap"),
256 severity: Severity::Error,
257 category: crate::rule::Category::Correctness,
258 message: ".unwrap() forbidden".to_string(),
259 note: None,
260 fix: None,
261 file: PathBuf::from("src/main.rs"),
262 line: 2,
263 column: 5,
264 end_line: 2,
265 end_column: 20,
266 matched_text: "foo().unwrap()".to_string(),
267 confidence: None,
268 escalated: false,
269 }
270 }
271
272 #[test]
273 fn cache_miss() {
274 let dir = tempdir().unwrap();
275 let store = CacheStore::new(dir.path());
276 let hash = file_content_hash(b"fn main() { foo().unwrap(); }");
277
278 assert!(store.get(&hash).is_none());
279 }
280
281 fn entry_with(findings: Vec<Finding>) -> CacheEntry {
282 CacheEntry {
283 findings,
284 ..Default::default()
285 }
286 }
287
288 #[test]
289 fn cache_hit() {
290 let dir = tempdir().unwrap();
291 let store = CacheStore::new(dir.path());
292 let content = b"fn main() { foo().unwrap(); }";
293 let hash = file_content_hash(content);
294
295 store
296 .put(&hash, &entry_with(vec![sample_finding()]))
297 .unwrap();
298 let cached = store.get(&hash).unwrap().findings;
299
300 assert_eq!(cached.len(), 1);
301 assert_eq!(cached[0].rule_id, RuleId::from("test-unwrap"));
302 assert_eq!(cached[0].line, 2);
303 assert_eq!(cached[0].matched_text, "foo().unwrap()");
304 }
305
306 #[test]
307 fn cache_entry_round_trips_symbols() {
308 let dir = tempdir().unwrap();
309 let store = CacheStore::new(dir.path());
310 let hash = file_content_hash(b"pub trait Repository {}");
311 let entry = CacheEntry {
312 findings: Vec::new(),
313 imports: FileImports::default(),
314 symbols: FileSymbols {
315 traits: vec![TraitDecl {
316 name: "Repository".to_string(),
317 line: 1,
318 column: 1,
319 end_line: 1,
320 end_column: 21,
321 in_cfg_test: false,
322 header: "pub trait Repository".to_string(),
323 }],
324 impls: vec![TraitImpl {
325 trait_name: "Repository".to_string(),
326 blanket: false,
327 }],
328 error_messages: Vec::new(),
329 },
330 };
331
332 store.put(&hash, &entry).unwrap();
333 let cached = store.get(&hash).unwrap();
334
335 assert_eq!(cached.symbols.traits.len(), 1);
336 assert_eq!(cached.symbols.traits[0].name, "Repository");
337 assert_eq!(cached.symbols.traits[0].header, "pub trait Repository");
338 assert_eq!(cached.symbols.impls.len(), 1);
339 assert_eq!(cached.symbols.impls[0].trait_name, "Repository");
340 assert!(!cached.symbols.impls[0].blanket);
341 }
342
343 #[test]
344 fn cache_entry_round_trips_imports() {
345 let dir = tempdir().unwrap();
346 let store = CacheStore::new(dir.path());
347 let hash = file_content_hash(b"use made_up_crate::Thing;");
348 let entry = CacheEntry {
349 imports: FileImports {
350 imports: vec![ImportRef {
351 specifier: "made_up_crate".to_string(),
352 line: 1,
353 column: 1,
354 end_line: 1,
355 end_column: 26,
356 matched_text: "use made_up_crate::Thing;".to_string(),
357 }],
358 local_mods: vec!["foo".to_string()],
359 },
360 ..Default::default()
361 };
362
363 store.put(&hash, &entry).unwrap();
364 let cached = store.get(&hash).unwrap();
365
366 assert_eq!(cached.imports.imports.len(), 1);
367 assert_eq!(cached.imports.imports[0].specifier, "made_up_crate");
368 assert_eq!(cached.imports.local_mods, vec!["foo".to_string()]);
369 }
370
371 #[test]
372 fn invalidation_file_change() {
373 let dir = tempdir().unwrap();
374 let store = CacheStore::new(dir.path());
375
376 let content_v1 = b"fn main() { foo().unwrap(); }";
377 let content_v2 = b"fn main() { foo()?; }";
378 let hash_v1 = file_content_hash(content_v1);
379 let hash_v2 = file_content_hash(content_v2);
380
381 store
382 .put(&hash_v1, &entry_with(vec![sample_finding()]))
383 .unwrap();
384
385 assert!(store.get(&hash_v1).is_some());
386 assert!(store.get(&hash_v2).is_none());
387 }
388
389 #[test]
390 fn invalidation_rule_change() {
391 let dir = tempdir().unwrap();
392 let store = CacheStore::new(dir.path());
393
394 let rules_v1 = [test_rule()];
395 let hash_v1 = rules_hash(&rules_v1);
396
397 let content_hash = file_content_hash(b"fn main() {}");
398 store.put(&content_hash, &entry_with(Vec::new())).unwrap();
399 store.write_rules_hash(&hash_v1).unwrap();
400
401 assert!(store.get(&content_hash).is_some());
402
403 let mut rule_v2 = test_rule();
404 rule_v2.id = RuleId::from("different-rule");
405 let rules_v2 = [rule_v2];
406 let hash_v2 = rules_hash(&rules_v2);
407 assert_ne!(hash_v1, hash_v2);
408
409 store.check_rules_changed(&hash_v2).unwrap();
410
411 assert!(
412 store.get(&content_hash).is_none(),
413 "cache entries should be invalidated when rules change"
414 );
415 }
416
417 #[test]
418 fn cleanup_orphan_entries() {
419 let dir = tempdir().unwrap();
420 let store = CacheStore::new(dir.path());
421
422 let hash_a = file_content_hash(b"file a");
423 let hash_b = file_content_hash(b"file b");
424 store.put(&hash_a, &entry_with(Vec::new())).unwrap();
425 store
426 .put(&hash_b, &entry_with(vec![sample_finding()]))
427 .unwrap();
428
429 let removed = store.cleanup(slice::from_ref(&hash_a)).unwrap();
430
431 assert_eq!(removed, 1);
432 assert!(store.get(&hash_a).is_some());
433 assert!(store.get(&hash_b).is_none());
434 }
435
436 #[test]
437 fn corrupted_cache_entry() {
438 let dir = tempdir().unwrap();
439 let store = CacheStore::new(dir.path());
440
441 store.ensure_dir().unwrap();
442 let hash = file_content_hash(b"some content");
443 fs::write(store.entry_path(&hash), b"not valid bincode data!!!").unwrap();
444
445 assert!(
446 store.get(&hash).is_none(),
447 "corrupted entry should return None, not panic"
448 );
449 }
450}