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}
64
65impl CacheStore {
66 pub fn new(project_root: &Path) -> Self {
67 Self {
68 dir: project_root.join(".slopguard-cache"),
69 }
70 }
71
72 pub fn with_dir(dir: PathBuf) -> Self {
75 Self { dir }
76 }
77
78 pub fn dir(&self) -> &Path {
79 &self.dir
80 }
81
82 fn ensure_dir(&self) -> Result<(), CacheError> {
83 ensure_gitignored_dir(&self.dir)?;
84 Ok(())
85 }
86
87 fn stored_rules_hash(&self) -> Option<String> {
88 fs::read_to_string(self.dir.join(RULES_HASH_FILE)).ok()
89 }
90
91 fn write_rules_hash(&self, hash: &str) -> Result<(), CacheError> {
92 self.ensure_dir()?;
93 fs::write(self.dir.join(RULES_HASH_FILE), hash)?;
94 Ok(())
95 }
96
97 pub fn check_rules_changed(&self, current_hash: &str) -> Result<bool, CacheError> {
100 match self.stored_rules_hash() {
101 Some(stored) if stored == current_hash => Ok(false),
102 _ => {
103 self.invalidate_all()?;
104 self.write_rules_hash(current_hash)?;
105 Ok(true)
106 }
107 }
108 }
109
110 fn invalidate_all(&self) -> Result<(), CacheError> {
111 if self.dir.exists() {
112 for entry in fs::read_dir(&self.dir)? {
113 let entry = entry?;
114 let name = entry.file_name();
115 let name_str = name.to_string_lossy();
116 if name_str != ".gitignore" && name_str != RULES_HASH_FILE {
117 fs::remove_file(entry.path())?;
118 }
119 }
120 }
121 Ok(())
122 }
123
124 fn entry_path(&self, file_hash: &str) -> PathBuf {
125 self.dir.join(format!("{file_hash}.bin"))
126 }
127
128 pub fn get(&self, file_hash: &str) -> Option<Vec<Finding>> {
130 let path = self.entry_path(file_hash);
131 let data = fs::read(&path).ok()?;
132 rmp_serde::from_slice(&data).ok()
133 }
134
135 pub fn put(&self, file_hash: &str, findings: &[Finding]) -> Result<(), CacheError> {
137 self.ensure_dir()?;
138 let data = rmp_serde::to_vec_named(findings).map_err(io::Error::other)?;
139 fs::write(self.entry_path(file_hash), data)?;
140 Ok(())
141 }
142
143 pub fn cleanup(&self, current_hashes: &[String]) -> Result<usize, CacheError> {
146 if !self.dir.exists() {
147 return Ok(0);
148 }
149 let mut removed = 0;
150 for entry in fs::read_dir(&self.dir)? {
151 let entry = entry?;
152 let name = entry.file_name();
153 let name_str = name.to_string_lossy();
154 if name_str == ".gitignore" || name_str == RULES_HASH_FILE {
155 continue;
156 }
157 let hash = name_str.trim_end_matches(".bin");
158 if !current_hashes.iter().any(|h| h == hash) {
159 fs::remove_file(entry.path())?;
160 removed += 1;
161 }
162 }
163 Ok(removed)
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use std::path::PathBuf;
170 use std::slice;
171
172 use tempfile::tempdir;
173
174 use super::*;
175 use crate::rule::{parse_rule, RuleId, Severity};
176
177 fn test_rule() -> Rule {
178 parse_rule(
179 r#"
180id: test-unwrap
181language: rust
182severity: error
183category: correctness
184message: ".unwrap() forbidden"
185rule:
186 kind: call_expression
187 regex: '\.unwrap\(\)\s*$'
188"#,
189 )
190 .unwrap()
191 }
192
193 fn sample_finding() -> Finding {
194 Finding {
195 rule_id: RuleId::from("test-unwrap"),
196 severity: Severity::Error,
197 category: crate::rule::Category::Correctness,
198 message: ".unwrap() forbidden".to_string(),
199 note: None,
200 fix: None,
201 file: PathBuf::from("src/main.rs"),
202 line: 2,
203 column: 5,
204 end_line: 2,
205 end_column: 20,
206 matched_text: "foo().unwrap()".to_string(),
207 confidence: None,
208 escalated: false,
209 }
210 }
211
212 #[test]
213 fn cache_miss() {
214 let dir = tempdir().unwrap();
215 let store = CacheStore::new(dir.path());
216 let hash = file_content_hash(b"fn main() { foo().unwrap(); }");
217
218 assert!(store.get(&hash).is_none());
219 }
220
221 #[test]
222 fn cache_hit() {
223 let dir = tempdir().unwrap();
224 let store = CacheStore::new(dir.path());
225 let content = b"fn main() { foo().unwrap(); }";
226 let hash = file_content_hash(content);
227 let findings = vec![sample_finding()];
228
229 store.put(&hash, &findings).unwrap();
230 let cached = store.get(&hash).unwrap();
231
232 assert_eq!(cached.len(), 1);
233 assert_eq!(cached[0].rule_id, RuleId::from("test-unwrap"));
234 assert_eq!(cached[0].line, 2);
235 assert_eq!(cached[0].matched_text, "foo().unwrap()");
236 }
237
238 #[test]
239 fn invalidation_file_change() {
240 let dir = tempdir().unwrap();
241 let store = CacheStore::new(dir.path());
242
243 let content_v1 = b"fn main() { foo().unwrap(); }";
244 let content_v2 = b"fn main() { foo()?; }";
245 let hash_v1 = file_content_hash(content_v1);
246 let hash_v2 = file_content_hash(content_v2);
247
248 store.put(&hash_v1, &[sample_finding()]).unwrap();
249
250 assert!(store.get(&hash_v1).is_some());
251 assert!(store.get(&hash_v2).is_none());
252 }
253
254 #[test]
255 fn invalidation_rule_change() {
256 let dir = tempdir().unwrap();
257 let store = CacheStore::new(dir.path());
258
259 let rules_v1 = [test_rule()];
260 let hash_v1 = rules_hash(&rules_v1);
261
262 let content_hash = file_content_hash(b"fn main() {}");
263 store.put(&content_hash, &[]).unwrap();
264 store.write_rules_hash(&hash_v1).unwrap();
265
266 assert!(store.get(&content_hash).is_some());
267
268 let mut rule_v2 = test_rule();
269 rule_v2.id = RuleId::from("different-rule");
270 let rules_v2 = [rule_v2];
271 let hash_v2 = rules_hash(&rules_v2);
272 assert_ne!(hash_v1, hash_v2);
273
274 store.check_rules_changed(&hash_v2).unwrap();
275
276 assert!(
277 store.get(&content_hash).is_none(),
278 "cache entries should be invalidated when rules change"
279 );
280 }
281
282 #[test]
283 fn cleanup_orphan_entries() {
284 let dir = tempdir().unwrap();
285 let store = CacheStore::new(dir.path());
286
287 let hash_a = file_content_hash(b"file a");
288 let hash_b = file_content_hash(b"file b");
289 store.put(&hash_a, &[]).unwrap();
290 store.put(&hash_b, &[sample_finding()]).unwrap();
291
292 let removed = store.cleanup(slice::from_ref(&hash_a)).unwrap();
293
294 assert_eq!(removed, 1);
295 assert!(store.get(&hash_a).is_some());
296 assert!(store.get(&hash_b).is_none());
297 }
298
299 #[test]
300 fn corrupted_cache_entry() {
301 let dir = tempdir().unwrap();
302 let store = CacheStore::new(dir.path());
303
304 store.ensure_dir().unwrap();
305 let hash = file_content_hash(b"some content");
306 fs::write(store.entry_path(&hash), b"not valid bincode data!!!").unwrap();
307
308 assert!(
309 store.get(&hash).is_none(),
310 "corrupted entry should return None, not panic"
311 );
312 }
313}