1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use sha2::{Digest, Sha256};
6use thiserror::Error;
7
8use crate::finding::Finding;
9use crate::rule::Rule;
10
11const RULES_HASH_FILE: &str = "rules.hash";
12const CACHE_GITIGNORE: &str = "*\n";
13
14pub fn sha256_hex(data: &[u8]) -> String {
16 let mut hasher = Sha256::new();
17 hasher.update(data);
18 format!("{:x}", hasher.finalize())
19}
20
21pub fn ensure_gitignored_dir(dir: &Path) -> Result<(), io::Error> {
28 if !dir.exists() {
29 fs::create_dir_all(dir)?;
30 fs::write(dir.join(".gitignore"), CACHE_GITIGNORE)?;
31 }
32 Ok(())
33}
34
35#[derive(Debug, Error)]
36pub enum CacheError {
37 #[error("cache I/O error: {0}")]
38 Io(#[from] io::Error),
39}
40
41pub fn rules_hash(rules: &[Rule]) -> String {
44 let mut ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
45 ids.sort();
46
47 let mut hasher = Sha256::new();
48 for id in &ids {
49 hasher.update(id.as_bytes());
50 hasher.update(b"\0");
51 }
52 format!("{:x}", hasher.finalize())
53}
54
55pub fn file_content_hash(content: &[u8]) -> String {
57 sha256_hex(content)
58}
59
60pub struct CacheStore {
62 dir: PathBuf,
63 key_prefix: String,
67}
68
69impl CacheStore {
70 pub fn new(project_root: &Path) -> Self {
71 Self {
72 dir: project_root.join(".slopguard-cache"),
73 key_prefix: String::new(),
74 }
75 }
76
77 pub fn with_dir(dir: PathBuf) -> Self {
80 Self {
81 dir,
82 key_prefix: String::new(),
83 }
84 }
85
86 pub fn scoped_to_rules(mut self, rules_hash: &str) -> Self {
91 self.key_prefix = format!("{rules_hash}-");
92 self
93 }
94
95 pub fn dir(&self) -> &Path {
96 &self.dir
97 }
98
99 fn ensure_dir(&self) -> Result<(), CacheError> {
100 ensure_gitignored_dir(&self.dir)?;
101 Ok(())
102 }
103
104 fn stored_rules_hash(&self) -> Option<String> {
105 fs::read_to_string(self.dir.join(RULES_HASH_FILE)).ok()
106 }
107
108 fn write_rules_hash(&self, hash: &str) -> Result<(), CacheError> {
109 self.ensure_dir()?;
110 fs::write(self.dir.join(RULES_HASH_FILE), hash)?;
111 Ok(())
112 }
113
114 pub fn check_rules_changed(&self, current_hash: &str) -> Result<bool, CacheError> {
117 match self.stored_rules_hash() {
118 Some(stored) if stored == current_hash => Ok(false),
119 _ => {
120 self.invalidate_all()?;
121 self.write_rules_hash(current_hash)?;
122 Ok(true)
123 }
124 }
125 }
126
127 fn invalidate_all(&self) -> Result<(), CacheError> {
128 if self.dir.exists() {
129 for entry in fs::read_dir(&self.dir)? {
130 let entry = entry?;
131 let name = entry.file_name();
132 let name_str = name.to_string_lossy();
133 if name_str != ".gitignore" && name_str != RULES_HASH_FILE {
134 fs::remove_file(entry.path())?;
135 }
136 }
137 }
138 Ok(())
139 }
140
141 fn entry_path(&self, file_hash: &str) -> PathBuf {
142 self.dir.join(format!("{}{file_hash}.bin", self.key_prefix))
143 }
144
145 pub fn get(&self, file_hash: &str) -> Option<Vec<Finding>> {
147 let path = self.entry_path(file_hash);
148 let data = fs::read(&path).ok()?;
149 rmp_serde::from_slice(&data).ok()
150 }
151
152 pub fn put(&self, file_hash: &str, findings: &[Finding]) -> Result<(), CacheError> {
154 self.ensure_dir()?;
155 let data = rmp_serde::to_vec_named(findings).map_err(io::Error::other)?;
156 fs::write(self.entry_path(file_hash), data)?;
157 Ok(())
158 }
159
160 pub fn cleanup(&self, current_hashes: &[String]) -> Result<usize, CacheError> {
163 if !self.dir.exists() {
164 return Ok(0);
165 }
166 let mut removed = 0;
167 for entry in fs::read_dir(&self.dir)? {
168 let entry = entry?;
169 let name = entry.file_name();
170 let name_str = name.to_string_lossy();
171 if name_str == ".gitignore" || name_str == RULES_HASH_FILE {
172 continue;
173 }
174 let Some(rest) = name_str.strip_prefix(&self.key_prefix) else {
177 continue;
178 };
179 let hash = rest.trim_end_matches(".bin");
180 if !current_hashes.iter().any(|h| h == hash) {
181 fs::remove_file(entry.path())?;
182 removed += 1;
183 }
184 }
185 Ok(removed)
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use std::path::PathBuf;
192 use std::slice;
193
194 use tempfile::tempdir;
195
196 use super::*;
197 use crate::rule::{parse_rule, RuleId, Severity};
198
199 fn test_rule() -> Rule {
200 parse_rule(
201 r#"
202id: test-unwrap
203language: rust
204severity: error
205category: correctness
206message: ".unwrap() forbidden"
207rule:
208 kind: call_expression
209 regex: '\.unwrap\(\)\s*$'
210"#,
211 )
212 .unwrap()
213 }
214
215 fn sample_finding() -> Finding {
216 Finding {
217 rule_id: RuleId::from("test-unwrap"),
218 severity: Severity::Error,
219 category: crate::rule::Category::Correctness,
220 message: ".unwrap() forbidden".to_string(),
221 note: None,
222 fix: None,
223 file: PathBuf::from("src/main.rs"),
224 line: 2,
225 column: 5,
226 end_line: 2,
227 end_column: 20,
228 matched_text: "foo().unwrap()".to_string(),
229 confidence: None,
230 escalated: false,
231 }
232 }
233
234 #[test]
235 fn cache_miss() {
236 let dir = tempdir().unwrap();
237 let store = CacheStore::new(dir.path());
238 let hash = file_content_hash(b"fn main() { foo().unwrap(); }");
239
240 assert!(store.get(&hash).is_none());
241 }
242
243 #[test]
244 fn cache_hit() {
245 let dir = tempdir().unwrap();
246 let store = CacheStore::new(dir.path());
247 let content = b"fn main() { foo().unwrap(); }";
248 let hash = file_content_hash(content);
249 let findings = vec![sample_finding()];
250
251 store.put(&hash, &findings).unwrap();
252 let cached = store.get(&hash).unwrap();
253
254 assert_eq!(cached.len(), 1);
255 assert_eq!(cached[0].rule_id, RuleId::from("test-unwrap"));
256 assert_eq!(cached[0].line, 2);
257 assert_eq!(cached[0].matched_text, "foo().unwrap()");
258 }
259
260 #[test]
261 fn invalidation_file_change() {
262 let dir = tempdir().unwrap();
263 let store = CacheStore::new(dir.path());
264
265 let content_v1 = b"fn main() { foo().unwrap(); }";
266 let content_v2 = b"fn main() { foo()?; }";
267 let hash_v1 = file_content_hash(content_v1);
268 let hash_v2 = file_content_hash(content_v2);
269
270 store.put(&hash_v1, &[sample_finding()]).unwrap();
271
272 assert!(store.get(&hash_v1).is_some());
273 assert!(store.get(&hash_v2).is_none());
274 }
275
276 #[test]
277 fn invalidation_rule_change() {
278 let dir = tempdir().unwrap();
279 let store = CacheStore::new(dir.path());
280
281 let rules_v1 = [test_rule()];
282 let hash_v1 = rules_hash(&rules_v1);
283
284 let content_hash = file_content_hash(b"fn main() {}");
285 store.put(&content_hash, &[]).unwrap();
286 store.write_rules_hash(&hash_v1).unwrap();
287
288 assert!(store.get(&content_hash).is_some());
289
290 let mut rule_v2 = test_rule();
291 rule_v2.id = RuleId::from("different-rule");
292 let rules_v2 = [rule_v2];
293 let hash_v2 = rules_hash(&rules_v2);
294 assert_ne!(hash_v1, hash_v2);
295
296 store.check_rules_changed(&hash_v2).unwrap();
297
298 assert!(
299 store.get(&content_hash).is_none(),
300 "cache entries should be invalidated when rules change"
301 );
302 }
303
304 #[test]
305 fn cleanup_orphan_entries() {
306 let dir = tempdir().unwrap();
307 let store = CacheStore::new(dir.path());
308
309 let hash_a = file_content_hash(b"file a");
310 let hash_b = file_content_hash(b"file b");
311 store.put(&hash_a, &[]).unwrap();
312 store.put(&hash_b, &[sample_finding()]).unwrap();
313
314 let removed = store.cleanup(slice::from_ref(&hash_a)).unwrap();
315
316 assert_eq!(removed, 1);
317 assert!(store.get(&hash_a).is_some());
318 assert!(store.get(&hash_b).is_none());
319 }
320
321 #[test]
322 fn corrupted_cache_entry() {
323 let dir = tempdir().unwrap();
324 let store = CacheStore::new(dir.path());
325
326 store.ensure_dir().unwrap();
327 let hash = file_content_hash(b"some content");
328 fs::write(store.entry_path(&hash), b"not valid bincode data!!!").unwrap();
329
330 assert!(
331 store.get(&hash).is_none(),
332 "corrupted entry should return None, not panic"
333 );
334 }
335}