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 = "v5";
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::{TestHelper, TraitDecl, TraitImpl, UnassertedTest};
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 self_type: "PostgresRepository".to_string(),
328 }],
329 error_messages: Vec::new(),
330 types: vec!["PostgresRepository".to_string()],
331 helpers: vec![TestHelper {
332 name: "check".to_string(),
333 asserts: true,
334 calls: vec!["parse".to_string()],
335 in_cfg_test: true,
336 }],
337 unasserted_tests: vec![UnassertedTest {
338 line: 3,
339 column: 1,
340 end_line: 5,
341 end_column: 2,
342 text: "fn t() {\n check();\n}".to_string(),
343 calls: vec!["check".to_string()],
344 }],
345 },
346 };
347
348 store.put(&hash, &entry).unwrap();
349 let cached = store.get(&hash).unwrap();
350
351 assert_eq!(cached.symbols.traits.len(), 1);
352 assert_eq!(cached.symbols.traits[0].name, "Repository");
353 assert_eq!(cached.symbols.traits[0].header, "pub trait Repository");
354 assert_eq!(cached.symbols.impls.len(), 1);
355 assert_eq!(cached.symbols.impls[0].trait_name, "Repository");
356 assert!(!cached.symbols.impls[0].blanket);
357 assert_eq!(cached.symbols.impls[0].self_type, "PostgresRepository");
358 assert_eq!(cached.symbols.types, vec!["PostgresRepository".to_string()]);
359 assert_eq!(cached.symbols.helpers.len(), 1);
360 assert_eq!(cached.symbols.helpers[0].name, "check");
361 assert!(cached.symbols.helpers[0].asserts);
362 assert_eq!(cached.symbols.helpers[0].calls, vec!["parse".to_string()]);
363 assert!(cached.symbols.helpers[0].in_cfg_test);
364 assert_eq!(cached.symbols.unasserted_tests.len(), 1);
365 assert_eq!(cached.symbols.unasserted_tests[0].line, 3);
366 assert_eq!(
367 cached.symbols.unasserted_tests[0].text,
368 "fn t() {\n check();\n}"
369 );
370 assert_eq!(
371 cached.symbols.unasserted_tests[0].calls,
372 vec!["check".to_string()]
373 );
374 }
375
376 #[test]
377 fn cache_entry_round_trips_imports() {
378 let dir = tempdir().unwrap();
379 let store = CacheStore::new(dir.path());
380 let hash = file_content_hash(b"use made_up_crate::Thing;");
381 let entry = CacheEntry {
382 imports: FileImports {
383 imports: vec![ImportRef {
384 specifier: "made_up_crate".to_string(),
385 line: 1,
386 column: 1,
387 end_line: 1,
388 end_column: 26,
389 matched_text: "use made_up_crate::Thing;".to_string(),
390 }],
391 local_names: vec!["foo".to_string()],
392 },
393 ..Default::default()
394 };
395
396 store.put(&hash, &entry).unwrap();
397 let cached = store.get(&hash).unwrap();
398
399 assert_eq!(cached.imports.imports.len(), 1);
400 assert_eq!(cached.imports.imports[0].specifier, "made_up_crate");
401 assert_eq!(cached.imports.local_names, vec!["foo".to_string()]);
402 }
403
404 #[test]
405 fn invalidation_file_change() {
406 let dir = tempdir().unwrap();
407 let store = CacheStore::new(dir.path());
408
409 let content_v1 = b"fn main() { foo().unwrap(); }";
410 let content_v2 = b"fn main() { foo()?; }";
411 let hash_v1 = file_content_hash(content_v1);
412 let hash_v2 = file_content_hash(content_v2);
413
414 store
415 .put(&hash_v1, &entry_with(vec![sample_finding()]))
416 .unwrap();
417
418 assert!(store.get(&hash_v1).is_some());
419 assert!(store.get(&hash_v2).is_none());
420 }
421
422 #[test]
423 fn invalidation_rule_change() {
424 let dir = tempdir().unwrap();
425 let store = CacheStore::new(dir.path());
426
427 let rules_v1 = [test_rule()];
428 let hash_v1 = rules_hash(&rules_v1);
429
430 let content_hash = file_content_hash(b"fn main() {}");
431 store.put(&content_hash, &entry_with(Vec::new())).unwrap();
432 store.write_rules_hash(&hash_v1).unwrap();
433
434 assert!(store.get(&content_hash).is_some());
435
436 let mut rule_v2 = test_rule();
437 rule_v2.id = RuleId::from("different-rule");
438 let rules_v2 = [rule_v2];
439 let hash_v2 = rules_hash(&rules_v2);
440 assert_ne!(hash_v1, hash_v2);
441
442 store.check_rules_changed(&hash_v2).unwrap();
443
444 assert!(
445 store.get(&content_hash).is_none(),
446 "cache entries should be invalidated when rules change"
447 );
448 }
449
450 #[test]
451 fn cleanup_orphan_entries() {
452 let dir = tempdir().unwrap();
453 let store = CacheStore::new(dir.path());
454
455 let hash_a = file_content_hash(b"file a");
456 let hash_b = file_content_hash(b"file b");
457 store.put(&hash_a, &entry_with(Vec::new())).unwrap();
458 store
459 .put(&hash_b, &entry_with(vec![sample_finding()]))
460 .unwrap();
461
462 let removed = store.cleanup(slice::from_ref(&hash_a)).unwrap();
463
464 assert_eq!(removed, 1);
465 assert!(store.get(&hash_a).is_some());
466 assert!(store.get(&hash_b).is_none());
467 }
468
469 #[test]
470 fn corrupted_cache_entry() {
471 let dir = tempdir().unwrap();
472 let store = CacheStore::new(dir.path());
473
474 store.ensure_dir().unwrap();
475 let hash = file_content_hash(b"some content");
476 fs::write(store.entry_path(&hash), b"not valid bincode data!!!").unwrap();
477
478 assert!(
479 store.get(&hash).is_none(),
480 "corrupted entry should return None, not panic"
481 );
482 }
483}