provable_contracts/lint/
cache.rs1use std::collections::hash_map::DefaultHasher;
10use std::hash::{Hash, Hasher};
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15use super::finding::LintFinding;
16
17#[derive(Debug, Serialize, Deserialize)]
19pub struct CacheEntry {
20 pub content_hash: String,
21 pub findings: Vec<LintFinding>,
22}
23
24#[derive(Debug, Clone, Default)]
26pub struct CacheStats {
27 pub total: usize,
28 pub hits: usize,
29 pub misses: usize,
30}
31
32impl CacheStats {
33 #[allow(clippy::cast_precision_loss)]
34 pub fn hit_rate(&self) -> f64 {
35 if self.total == 0 {
36 0.0
37 } else {
38 self.hits as f64 / self.total as f64
39 }
40 }
41}
42
43pub fn content_hash(yaml_content: &str, rule_config: &str) -> String {
45 let mut hasher = DefaultHasher::new();
46 yaml_content.hash(&mut hasher);
47 rule_config.hash(&mut hasher);
48 format!("{:016x}", hasher.finish())
49}
50
51pub fn cache_dir(base: &Path) -> PathBuf {
53 base.join(".pv").join("cache").join("lint")
54}
55
56pub fn cache_get(cache_root: &Path, hash: &str) -> Option<CacheEntry> {
58 let path = cache_root.join(format!("{hash}.json"));
59 let content = std::fs::read_to_string(path).ok()?;
60 serde_json::from_str(&content).ok()
61}
62
63pub fn cache_put(cache_root: &Path, hash: &str, findings: &[LintFinding]) -> Result<(), String> {
65 std::fs::create_dir_all(cache_root).map_err(|e| format!("Failed to create cache dir: {e}"))?;
66
67 let entry = CacheEntry {
68 content_hash: hash.to_string(),
69 findings: findings.to_vec(),
70 };
71
72 let json = serde_json::to_string(&entry)
73 .map_err(|e| format!("Failed to serialize cache entry: {e}"))?;
74
75 let path = cache_root.join(format!("{hash}.json"));
76 std::fs::write(path, json).map_err(|e| format!("Failed to write cache entry: {e}"))?;
77
78 Ok(())
79}
80
81pub fn cache_clear(cache_root: &Path) -> Result<usize, String> {
83 let mut count = 0;
84 if cache_root.is_dir() {
85 let entries =
86 std::fs::read_dir(cache_root).map_err(|e| format!("Failed to read cache dir: {e}"))?;
87 for entry in entries.flatten() {
88 if entry.path().extension().and_then(|e| e.to_str()) == Some("json") {
89 let _ = std::fs::remove_file(entry.path());
90 count += 1;
91 }
92 }
93 }
94 Ok(count)
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use crate::lint::rules::RuleSeverity;
101
102 #[test]
103 fn content_hash_deterministic() {
104 let h1 = content_hash("yaml content", "rules");
105 let h2 = content_hash("yaml content", "rules");
106 assert_eq!(h1, h2);
107 }
108
109 #[test]
110 fn content_hash_differs_on_content() {
111 let h1 = content_hash("yaml A", "rules");
112 let h2 = content_hash("yaml B", "rules");
113 assert_ne!(h1, h2);
114 }
115
116 #[test]
117 fn content_hash_differs_on_rules() {
118 let h1 = content_hash("yaml", "rules A");
119 let h2 = content_hash("yaml", "rules B");
120 assert_ne!(h1, h2);
121 }
122
123 #[test]
124 fn cache_roundtrip() {
125 let tmp = tempfile::tempdir().unwrap();
126 let cache = tmp.path().join("cache");
127
128 let finding = LintFinding::new("PV-VAL-001", RuleSeverity::Error, "test", "file.yaml");
129 cache_put(&cache, "abc123", &[finding]).unwrap();
130
131 let entry = cache_get(&cache, "abc123").unwrap();
132 assert_eq!(entry.content_hash, "abc123");
133 assert_eq!(entry.findings.len(), 1);
134 assert_eq!(entry.findings[0].rule_id, "PV-VAL-001");
135 }
136
137 #[test]
138 fn cache_miss() {
139 let tmp = tempfile::tempdir().unwrap();
140 assert!(cache_get(tmp.path(), "nonexistent").is_none());
141 }
142
143 #[test]
144 fn cache_clear_empty() {
145 let tmp = tempfile::tempdir().unwrap();
146 let count = cache_clear(tmp.path()).unwrap();
147 assert_eq!(count, 0);
148 }
149
150 #[test]
151 fn cache_clear_removes_entries() {
152 let tmp = tempfile::tempdir().unwrap();
153 let cache = tmp.path().join("cache");
154
155 let finding = LintFinding::new("PV-VAL-001", RuleSeverity::Error, "t", "f.yaml");
156 cache_put(&cache, "hash1", std::slice::from_ref(&finding)).unwrap();
157 cache_put(&cache, "hash2", std::slice::from_ref(&finding)).unwrap();
158
159 let count = cache_clear(&cache).unwrap();
160 assert_eq!(count, 2);
161 assert!(cache_get(&cache, "hash1").is_none());
162 }
163
164 #[test]
165 fn cache_stats_default() {
166 let stats = CacheStats::default();
167 assert_eq!(stats.total, 0);
168 assert!(stats.hit_rate().abs() < f64::EPSILON);
169 }
170
171 #[test]
172 fn cache_stats_hit_rate() {
173 let stats = CacheStats {
174 total: 10,
175 hits: 7,
176 misses: 3,
177 };
178 assert!((stats.hit_rate() - 0.7).abs() < f64::EPSILON);
179 }
180
181 #[test]
182 fn cache_dir_path() {
183 let base = Path::new("/repo");
184 let dir = cache_dir(base);
185 assert_eq!(dir, PathBuf::from("/repo/.pv/cache/lint"));
186 }
187}