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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ValidationCacheStatus {
12 Hit,
14 Miss,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct ValidationError {
21 pub instance_path: String,
23 pub message: String,
25 #[serde(default)]
27 pub schema_path: String,
28}
29
30#[derive(Serialize, Deserialize)]
31struct CachedResult {
32 errors: Vec<ValidationError>,
33}
34
35#[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 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 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 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
128pub 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
138pub 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 cache_write.store("content", &hash, true, &[]).await;
240
241 let (result, status) = cache_skip.lookup("content", &hash, true).await;
243 assert_eq!(status, ValidationCacheStatus::Miss);
244 assert!(result.is_none());
245
246 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}