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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ValidationCacheStatus {
15 Hit,
17 Miss,
19}
20
21pub struct CacheKey<'a> {
23 pub file_content: &'a str,
25 pub schema_hash: &'a str,
27 pub validate_formats: bool,
29}
30
31#[derive(Serialize, Deserialize)]
32struct CachedResult {
33 errors: Vec<ValidationError>,
34}
35
36#[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 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 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 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
121pub 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
131pub 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 let key = CacheKey {
274 file_content: "content",
275 schema_hash: &hash,
276 validate_formats: true,
277 };
278 cache_write.store(&key, &[]).await;
279
280 let (result, status) = cache_skip.lookup(&key).await;
282 assert_eq!(status, ValidationCacheStatus::Miss);
283 assert!(result.is_none());
284
285 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}