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