Skip to main content

lintel_validation_cache/
lib.rs

1#![doc = include_str!("../README.md")]
2
3pub mod validation_error;
4pub use validation_error::*;
5
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use sha2::{Digest, Sha256};
11
12/// Whether a validation result was served from the disk cache or freshly computed.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ValidationCacheStatus {
15    /// Validation result was found in the disk cache.
16    Hit,
17    /// Validation result was computed (cache miss or skip-read mode).
18    Miss,
19}
20
21/// The cache lookup/store key: file content, schema hash, and format-validation flag.
22pub struct CacheKey<'a> {
23    /// The raw file content being validated.
24    pub file_content: &'a str,
25    /// Pre-computed SHA-256 hash of the schema (see [`schema_hash`]).
26    pub schema_hash: &'a str,
27    /// Whether format validation was enabled.
28    pub validate_formats: bool,
29}
30
31#[derive(Serialize, Deserialize)]
32struct CachedResult {
33    errors: Vec<ValidationError>,
34}
35
36/// A disk-backed cache for JSON Schema validation results.
37///
38/// Results are keyed by `SHA-256(crate_version + file_content + schema_json + validate_formats_byte)`.
39/// Cache files are stored as `<cache_dir>/<sha256-hex>.json`.
40#[derive(Clone)]
41pub struct ValidationCache {
42    cache_dir: PathBuf,
43    skip_read: bool,
44}
45
46impl ValidationCache {
47    pub fn new(cache_dir: PathBuf, skip_read: bool) -> Self {
48        Self {
49            cache_dir,
50            skip_read,
51        }
52    }
53
54    /// Look up a cached validation result.
55    ///
56    /// Returns `(Some(errors), Hit)` on cache hit.
57    /// Returns `(None, Miss)` on cache miss or when `skip_read` is set.
58    ///
59    /// `key.schema_hash` should be obtained from [`schema_hash`] — pass the same
60    /// value for all files in a schema group to avoid redundant serialization.
61    pub async fn lookup(
62        &self,
63        key: &CacheKey<'_>,
64    ) -> (Option<Vec<ValidationError>>, ValidationCacheStatus) {
65        if self.skip_read {
66            return (None, ValidationCacheStatus::Miss);
67        }
68
69        let hash = Self::cache_key(key);
70        let cache_path = self.cache_dir.join(format!("{hash}.json"));
71
72        let Ok(data) = tokio::fs::read_to_string(&cache_path).await else {
73            return (None, ValidationCacheStatus::Miss);
74        };
75
76        let Ok(cached) = serde_json::from_str::<CachedResult>(&data) else {
77            return (None, ValidationCacheStatus::Miss);
78        };
79
80        (Some(cached.errors), ValidationCacheStatus::Hit)
81    }
82
83    /// Store a validation result to the disk cache.
84    ///
85    /// Always writes regardless of `skip_read`, so running with
86    /// `--force-validation` repopulates the cache for future runs.
87    ///
88    /// `key.schema_hash` should be obtained from [`schema_hash`] — pass the same
89    /// value for all files in a schema group to avoid redundant serialization.
90    pub async fn store(&self, key: &CacheKey<'_>, errors: &[ValidationError]) {
91        let hash = Self::cache_key(key);
92        let cache_path = self.cache_dir.join(format!("{hash}.json"));
93
94        let cached = CachedResult {
95            errors: errors.to_vec(),
96        };
97
98        let Ok(json) = serde_json::to_string(&cached) else {
99            return;
100        };
101
102        if tokio::fs::create_dir_all(&self.cache_dir).await.is_ok() {
103            let _ = tokio::fs::write(&cache_path, json).await;
104        }
105    }
106
107    /// Compute the SHA-256 cache key from a [`CacheKey`].
108    ///
109    /// The crate version is included in the hash so that upgrading lintel
110    /// automatically invalidates stale cache entries.
111    pub fn cache_key(key: &CacheKey<'_>) -> String {
112        let mut hasher = Sha256::new();
113        hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
114        hasher.update(key.file_content.as_bytes());
115        hasher.update(key.schema_hash.as_bytes());
116        hasher.update([u8::from(key.validate_formats)]);
117        format!("{:x}", hasher.finalize())
118    }
119}
120
121/// Compute a SHA-256 hash of a schema `Value`.
122///
123/// Call this once per schema group and pass the result to
124/// [`ValidationCache::lookup`] and [`ValidationCache::store`].
125pub fn schema_hash(schema: &Value) -> String {
126    let mut hasher = Sha256::new();
127    hasher.update(schema.to_string().as_bytes());
128    format!("{:x}", hasher.finalize())
129}
130
131/// Return a usable cache directory for validation results, creating it if necessary.
132///
133/// Tries `<system_cache>/lintel/validations` first, falling back to
134/// `<temp_dir>/lintel/validations` when the preferred path is unwritable.
135pub fn ensure_cache_dir() -> PathBuf {
136    let candidates = [
137        dirs::cache_dir().map(|d| d.join("lintel").join("validations")),
138        Some(std::env::temp_dir().join("lintel").join("validations")),
139    ];
140    for candidate in candidates.into_iter().flatten() {
141        if std::fs::create_dir_all(&candidate).is_ok() {
142            return candidate;
143        }
144    }
145    std::env::temp_dir().join("lintel").join("validations")
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn sample_schema() -> Value {
153        serde_json::json!({"type": "object", "properties": {"name": {"type": "string"}}})
154    }
155
156    #[test]
157    fn cache_key_deterministic() {
158        let hash = schema_hash(&sample_schema());
159        let key = CacheKey {
160            file_content: "hello",
161            schema_hash: &hash,
162            validate_formats: true,
163        };
164        let a = ValidationCache::cache_key(&key);
165        let b = ValidationCache::cache_key(&key);
166        assert_eq!(a, b);
167    }
168
169    #[test]
170    fn cache_key_differs_on_content() {
171        let hash = schema_hash(&sample_schema());
172        let a = ValidationCache::cache_key(&CacheKey {
173            file_content: "hello",
174            schema_hash: &hash,
175            validate_formats: true,
176        });
177        let b = ValidationCache::cache_key(&CacheKey {
178            file_content: "world",
179            schema_hash: &hash,
180            validate_formats: true,
181        });
182        assert_ne!(a, b);
183    }
184
185    #[test]
186    fn cache_key_differs_on_schema() {
187        let hash_a = schema_hash(&sample_schema());
188        let hash_b = schema_hash(&serde_json::json!({"type": "string"}));
189        let a = ValidationCache::cache_key(&CacheKey {
190            file_content: "hello",
191            schema_hash: &hash_a,
192            validate_formats: true,
193        });
194        let b = ValidationCache::cache_key(&CacheKey {
195            file_content: "hello",
196            schema_hash: &hash_b,
197            validate_formats: true,
198        });
199        assert_ne!(a, b);
200    }
201
202    #[test]
203    fn cache_key_differs_on_formats() {
204        let hash = schema_hash(&sample_schema());
205        let a = ValidationCache::cache_key(&CacheKey {
206            file_content: "hello",
207            schema_hash: &hash,
208            validate_formats: true,
209        });
210        let b = ValidationCache::cache_key(&CacheKey {
211            file_content: "hello",
212            schema_hash: &hash,
213            validate_formats: false,
214        });
215        assert_ne!(a, b);
216    }
217
218    #[tokio::test]
219    async fn store_and_lookup() -> anyhow::Result<()> {
220        let tmp = tempfile::tempdir()?;
221        let cache = ValidationCache::new(tmp.path().to_path_buf(), false);
222        let hash = schema_hash(&sample_schema());
223
224        let errors = vec![ValidationError {
225            instance_path: "/name".to_string(),
226            schema_path: "/required".to_string(),
227            kind: ValidationErrorKind::Required {
228                property: "\"name\"".to_string(),
229            },
230            span: (0, 0),
231        }];
232        let key = CacheKey {
233            file_content: "content",
234            schema_hash: &hash,
235            validate_formats: true,
236        };
237        cache.store(&key, &errors).await;
238
239        let (result, status) = cache.lookup(&key).await;
240        assert_eq!(status, ValidationCacheStatus::Hit);
241        let result = result.expect("expected cache hit");
242        assert_eq!(result.len(), 1);
243        assert_eq!(result[0].instance_path, "/name");
244        assert_eq!(result[0].schema_path, "/required");
245        Ok(())
246    }
247
248    #[tokio::test]
249    async fn lookup_miss() -> anyhow::Result<()> {
250        let tmp = tempfile::tempdir()?;
251        let cache = ValidationCache::new(tmp.path().to_path_buf(), false);
252        let hash = schema_hash(&sample_schema());
253
254        let key = CacheKey {
255            file_content: "content",
256            schema_hash: &hash,
257            validate_formats: true,
258        };
259        let (result, status) = cache.lookup(&key).await;
260        assert_eq!(status, ValidationCacheStatus::Miss);
261        assert!(result.is_none());
262        Ok(())
263    }
264
265    #[tokio::test]
266    async fn skip_read_forces_miss() -> anyhow::Result<()> {
267        let tmp = tempfile::tempdir()?;
268        let cache_write = ValidationCache::new(tmp.path().to_path_buf(), false);
269        let cache_skip = ValidationCache::new(tmp.path().to_path_buf(), true);
270        let hash = schema_hash(&sample_schema());
271
272        // Store a result
273        let key = CacheKey {
274            file_content: "content",
275            schema_hash: &hash,
276            validate_formats: true,
277        };
278        cache_write.store(&key, &[]).await;
279
280        // With skip_read, lookup always returns miss
281        let (result, status) = cache_skip.lookup(&key).await;
282        assert_eq!(status, ValidationCacheStatus::Miss);
283        assert!(result.is_none());
284
285        // But store still writes (verify by reading with non-skip cache)
286        let key_other = CacheKey {
287            file_content: "other",
288            schema_hash: &hash,
289            validate_formats: true,
290        };
291        cache_skip
292            .store(
293                &key_other,
294                &[ValidationError {
295                    instance_path: "path".to_string(),
296                    schema_path: String::new(),
297                    kind: ValidationErrorKind::FalseSchema,
298                    span: (0, 0),
299                }],
300            )
301            .await;
302        let (result, status) = cache_write.lookup(&key_other).await;
303        assert_eq!(status, ValidationCacheStatus::Hit);
304        assert!(result.is_some());
305        Ok(())
306    }
307
308    #[test]
309    fn ensure_cache_dir_ends_with_validations() {
310        let dir = ensure_cache_dir();
311        assert!(dir.ends_with("lintel/validations"));
312    }
313}