1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13#[derive(Error, Debug)]
15pub enum CacheError {
16 #[error("I/O error: {0}")]
18 Io(#[from] std::io::Error),
19 #[error("serialization error: {0}")]
21 Serde(#[from] serde_json::Error),
22}
23
24#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
26pub struct CacheKey {
27 pub source_hash: String,
29 pub lock_hash: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CachedValue {
36 pub value_json: String,
38 pub timestamp: i64,
40}
41
42pub struct EvalCache {
46 memory: HashMap<CacheKey, CachedValue>,
47 db_path: Option<PathBuf>,
48 enabled: bool,
49}
50
51impl EvalCache {
52 #[must_use]
55 pub fn new(persist: bool) -> Self {
56 let db_path = if persist {
57 dirs_next::cache_dir().map(|d| d.join("sui").join("eval-cache.json"))
58 } else {
59 None
60 };
61
62 let memory = db_path
63 .as_ref()
64 .map_or_else(HashMap::new, |p| Self::load_from(p));
65
66 Self {
67 memory,
68 db_path,
69 enabled: true,
70 }
71 }
72
73 #[must_use]
75 pub fn with_path(path: PathBuf) -> Self {
76 let memory = Self::load_from(&path);
77
78 Self {
79 memory,
80 db_path: Some(path),
81 enabled: true,
82 }
83 }
84
85 pub fn key_for_file(path: &Path, lock_path: Option<&Path>) -> Result<CacheKey, CacheError> {
91 let source = std::fs::read(path)?;
92 let source_hash = blake3::hash(&source).to_hex().to_string();
93
94 let lock_hash = if let Some(lp) = lock_path {
95 let lock = std::fs::read(lp)?;
96 Some(blake3::hash(&lock).to_hex().to_string())
97 } else {
98 None
99 };
100
101 Ok(CacheKey {
102 source_hash,
103 lock_hash,
104 })
105 }
106
107 #[must_use]
109 pub fn key_for_expr(expr: &str) -> CacheKey {
110 CacheKey {
111 source_hash: blake3::hash(expr.as_bytes()).to_hex().to_string(),
112 lock_hash: None,
113 }
114 }
115
116 #[must_use]
118 pub fn get(&self, key: &CacheKey) -> Option<&CachedValue> {
119 if !self.enabled {
120 return None;
121 }
122 self.memory.get(key)
123 }
124
125 pub fn put(&mut self, key: CacheKey, value: CachedValue) {
127 if !self.enabled {
128 return;
129 }
130 self.memory.insert(key, value);
131 self.persist();
132 }
133
134 #[must_use]
136 pub fn len(&self) -> usize {
137 self.memory.len()
138 }
139
140 #[must_use]
142 pub fn is_empty(&self) -> bool {
143 self.memory.is_empty()
144 }
145
146 pub fn clear(&mut self) {
148 self.memory.clear();
149 self.persist();
150 }
151
152 fn load_from(path: &Path) -> HashMap<CacheKey, CachedValue> {
154 let Ok(data) = std::fs::read_to_string(path) else {
155 return HashMap::new();
156 };
157 let Ok(entries): Result<Vec<(CacheKey, CachedValue)>, _> = serde_json::from_str(&data)
158 else {
159 return HashMap::new();
160 };
161 entries.into_iter().collect()
162 }
163
164 fn persist(&self) {
166 if let Some(ref path) = self.db_path {
167 if let Some(parent) = path.parent() {
168 let _ = std::fs::create_dir_all(parent);
169 }
170 let entries: Vec<(&CacheKey, &CachedValue)> = self.memory.iter().collect();
171 if let Ok(json) = serde_json::to_string(&entries) {
172 let _ = std::fs::write(path, json);
173 }
174 }
175 }
176}
177
178impl Default for EvalCache {
179 fn default() -> Self {
180 Self::new(false)
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn empty_cache() {
190 let cache = EvalCache::default();
191 assert!(cache.is_empty());
192 assert_eq!(cache.len(), 0);
193 }
194
195 #[test]
196 fn put_and_get() {
197 let mut cache = EvalCache::default();
198 let key = CacheKey {
199 source_hash: "abc".into(),
200 lock_hash: None,
201 };
202 let val = CachedValue {
203 value_json: "42".into(),
204 timestamp: 0,
205 };
206 cache.put(key.clone(), val);
207 assert_eq!(cache.get(&key).unwrap().value_json, "42");
208 assert_eq!(cache.len(), 1);
209 }
210
211 #[test]
212 fn key_for_expr_deterministic() {
213 let k1 = EvalCache::key_for_expr("1 + 2");
214 let k2 = EvalCache::key_for_expr("1 + 2");
215 let k3 = EvalCache::key_for_expr("1 + 3");
216 assert_eq!(k1, k2);
217 assert_ne!(k1, k3);
218 }
219
220 #[test]
221 fn different_lock_hash_is_different_key() {
222 let k1 = CacheKey {
223 source_hash: "same".into(),
224 lock_hash: Some("lock_a".into()),
225 };
226 let k2 = CacheKey {
227 source_hash: "same".into(),
228 lock_hash: Some("lock_b".into()),
229 };
230 assert_ne!(k1, k2);
231 }
232
233 #[test]
234 fn clear_removes_all() {
235 let mut cache = EvalCache::default();
236 cache.put(
237 CacheKey {
238 source_hash: "a".into(),
239 lock_hash: None,
240 },
241 CachedValue {
242 value_json: "1".into(),
243 timestamp: 0,
244 },
245 );
246 cache.put(
247 CacheKey {
248 source_hash: "b".into(),
249 lock_hash: None,
250 },
251 CachedValue {
252 value_json: "2".into(),
253 timestamp: 0,
254 },
255 );
256 assert_eq!(cache.len(), 2);
257 cache.clear();
258 assert!(cache.is_empty());
259 }
260
261 #[test]
262 fn disabled_cache_returns_none() {
263 let mut cache = EvalCache::default();
264 cache.enabled = false;
265 let key = CacheKey {
266 source_hash: "x".into(),
267 lock_hash: None,
268 };
269 cache.put(
270 key.clone(),
271 CachedValue {
272 value_json: "v".into(),
273 timestamp: 0,
274 },
275 );
276 assert!(cache.get(&key).is_none());
277 assert!(cache.is_empty());
278 }
279
280 #[test]
281 fn persistence_roundtrip() {
282 let dir = tempfile::TempDir::new().unwrap();
283 let path = dir.path().join("cache.json");
284
285 let key = CacheKey {
286 source_hash: "test".into(),
287 lock_hash: Some("lock123".into()),
288 };
289
290 {
292 let mut cache = EvalCache::with_path(path.clone());
293 cache.put(
294 key.clone(),
295 CachedValue {
296 value_json: r#""hello""#.into(),
297 timestamp: 123,
298 },
299 );
300 }
301
302 {
304 let cache = EvalCache::with_path(path);
305 let cached = cache.get(&key).expect("should load persisted entry");
306 assert_eq!(cached.value_json, r#""hello""#);
307 assert_eq!(cached.timestamp, 123);
308 }
309 }
310
311 #[test]
312 fn key_for_file_works() {
313 let dir = tempfile::TempDir::new().unwrap();
314 let src = dir.path().join("expr.nix");
315 let lock = dir.path().join("flake.lock");
316
317 std::fs::write(&src, "builtins.add 1 2").unwrap();
318 std::fs::write(&lock, r#"{"nodes":{}}"#).unwrap();
319
320 let k1 = EvalCache::key_for_file(&src, Some(&lock)).unwrap();
321 let k2 = EvalCache::key_for_file(&src, Some(&lock)).unwrap();
322 assert_eq!(k1, k2);
323 assert!(k1.lock_hash.is_some());
324
325 let k3 = EvalCache::key_for_file(&src, None).unwrap();
327 assert!(k3.lock_hash.is_none());
328 assert_eq!(k1.source_hash, k3.source_hash);
329 }
330
331 #[test]
332 fn key_for_file_missing_returns_error() {
333 let result = EvalCache::key_for_file(Path::new("/nonexistent/file.nix"), None);
334 assert!(result.is_err());
335 }
336
337 #[test]
338 fn overwrite_existing_key() {
339 let mut cache = EvalCache::default();
340 let key = CacheKey {
341 source_hash: "same".into(),
342 lock_hash: None,
343 };
344 cache.put(
345 key.clone(),
346 CachedValue {
347 value_json: "old".into(),
348 timestamp: 1,
349 },
350 );
351 cache.put(
352 key.clone(),
353 CachedValue {
354 value_json: "new".into(),
355 timestamp: 2,
356 },
357 );
358 assert_eq!(cache.len(), 1);
359 assert_eq!(cache.get(&key).unwrap().value_json, "new");
360 assert_eq!(cache.get(&key).unwrap().timestamp, 2);
361 }
362}