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