Skip to main content

lintel_validation_cache/
lib.rs

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