Skip to main content

provable_contracts/lint/
cache.rs

1//! Content-addressable lint cache.
2//!
3//! Each contract's lint result is cached using a hash of (YAML content +
4//! rule config). Cache is stored in `.pv/cache/lint/`. Automatically
5//! invalidated when any input changes.
6//!
7//! Spec: `docs/specifications/sub/lint.md` Section 10
8
9use 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/// Cache entry for one contract's lint results.
18#[derive(Debug, Serialize, Deserialize)]
19pub struct CacheEntry {
20    pub content_hash: String,
21    pub findings: Vec<LintFinding>,
22}
23
24/// Cache statistics.
25#[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
43/// Compute a content hash for cache key.
44pub 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
51/// Get the cache directory path.
52pub fn cache_dir(base: &Path) -> PathBuf {
53    base.join(".pv").join("cache").join("lint")
54}
55
56/// Look up a cached result.
57pub 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
63/// Store a lint result in the cache.
64pub 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
81/// Clear all cached results.
82pub 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}