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