1use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34use sha2::{Digest, Sha256};
35use sui_graph_store::{GraphHash, GraphKind, GraphStore};
36
37#[derive(Hash, Eq, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
41pub struct CacheKey {
42 pub source_hash: String,
44 pub lock_hash: Option<String>,
46}
47
48#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
50pub struct CachedValue {
51 pub value_json: String,
53 pub timestamp: i64,
55}
56
57#[derive(serde::Serialize, serde::Deserialize)]
59struct CacheEntry {
60 key: CacheKey,
61 value: CachedValue,
62}
63
64pub struct EvalCache {
68 memory: HashMap<CacheKey, CachedValue>,
70 db_path: Option<PathBuf>,
72 graph_store: Option<GraphStore>,
76 enabled: bool,
78}
79
80impl EvalCache {
81 pub fn new() -> Self {
83 Self {
84 memory: HashMap::new(),
85 db_path: None,
86 graph_store: None,
87 enabled: true,
88 }
89 }
90
91 pub fn with_persistent(db_path: PathBuf) -> Self {
94 let memory = Self::load_from_disk(&db_path).unwrap_or_default();
95 Self {
96 memory,
97 db_path: Some(db_path),
98 graph_store: None,
99 enabled: true,
100 }
101 }
102
103 pub fn default_persistent() -> Self {
105 match default_cache_path() {
106 Some(p) => Self::with_persistent(p),
107 None => Self::new(),
108 }
109 }
110
111 pub fn disabled() -> Self {
113 Self {
114 memory: HashMap::new(),
115 db_path: None,
116 graph_store: None,
117 enabled: false,
118 }
119 }
120
121 #[must_use]
125 pub fn with_graph_store(mut self, store: GraphStore) -> Self {
126 self.graph_store = Some(store);
127 self
128 }
129
130 #[must_use]
136 pub fn with_all_tiers(db_path: PathBuf, store: GraphStore) -> Self {
137 Self::with_persistent(db_path).with_graph_store(store)
138 }
139
140 pub fn is_enabled(&self) -> bool {
142 self.enabled
143 }
144
145 pub fn has_graph_store(&self) -> bool {
147 self.graph_store.is_some()
148 }
149
150 pub fn get(&mut self, key: &CacheKey) -> Option<&CachedValue> {
156 if !self.enabled {
157 return None;
158 }
159 if self.memory.contains_key(key) {
161 return self.memory.get(key);
162 }
163 if let Some(store) = &self.graph_store {
165 let gh = graph_hash_for_key(key);
166 if let Ok(blob) = store.get(GraphKind::EvalCacheEntry, gh) {
167 if let Ok(value) = serde_json::from_slice::<CachedValue>(&blob) {
168 self.memory.insert(key.clone(), value);
169 return self.memory.get(key);
170 }
171 }
172 }
173 None
174 }
175
176 pub fn put(&mut self, key: CacheKey, value: CachedValue) {
182 if !self.enabled {
183 return;
184 }
185 self.memory.insert(key.clone(), value.clone());
187 if let Some(ref path) = self.db_path {
189 let _ = Self::save_to_disk(path, &self.memory);
190 }
191 if let Some(store) = &self.graph_store {
198 if let Ok(blob) = serde_json::to_vec(&value) {
199 let lookup_hash = graph_hash_for_key(&key);
200 let _ = store.put_unchecked(GraphKind::EvalCacheEntry, lookup_hash, &blob);
201 }
202 }
203 }
204
205 pub fn len(&self) -> usize {
207 self.memory.len()
208 }
209
210 pub fn is_empty(&self) -> bool {
212 self.memory.is_empty()
213 }
214
215 pub fn key_for_file(path: &Path) -> Option<CacheKey> {
220 let content = std::fs::read(path).ok()?;
221 let source_hash = sha256_hex(&content);
222
223 let lock_hash = path
224 .parent()
225 .map(|dir| dir.join("flake.lock"))
226 .filter(|p| p.exists())
227 .and_then(|p| std::fs::read(p).ok())
228 .map(|c| sha256_hex(&c));
229
230 Some(CacheKey {
231 source_hash,
232 lock_hash,
233 })
234 }
235
236 fn load_from_disk(path: &Path) -> Option<HashMap<CacheKey, CachedValue>> {
239 let data = std::fs::read_to_string(path).ok()?;
240 let entries: Vec<CacheEntry> = serde_json::from_str(&data).ok()?;
241 let mut map = HashMap::with_capacity(entries.len());
242 for entry in entries {
243 map.insert(entry.key, entry.value);
244 }
245 Some(map)
246 }
247
248 fn save_to_disk(
249 path: &Path,
250 memory: &HashMap<CacheKey, CachedValue>,
251 ) -> Result<(), std::io::Error> {
252 if let Some(parent) = path.parent() {
253 std::fs::create_dir_all(parent)?;
254 }
255 let entries: Vec<CacheEntry> = memory
256 .iter()
257 .map(|(k, v)| CacheEntry {
258 key: k.clone(),
259 value: v.clone(),
260 })
261 .collect();
262 let json = serde_json::to_string(&entries)
263 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
264 std::fs::write(path, json)
265 }
266}
267
268impl Default for EvalCache {
269 fn default() -> Self {
270 Self::new()
271 }
272}
273
274fn graph_hash_for_key(key: &CacheKey) -> GraphHash {
282 let mut hasher = blake3::Hasher::new();
283 hasher.update(b"evalcache::v1::");
284 hasher.update(key.source_hash.as_bytes());
285 hasher.update(b"::");
286 if let Some(lock) = &key.lock_hash {
287 hasher.update(lock.as_bytes());
288 } else {
289 hasher.update(b"<no-lock>");
290 }
291 GraphHash(hasher.finalize().into())
292}
293
294fn sha256_hex(data: &[u8]) -> String {
296 let mut hasher = Sha256::new();
297 hasher.update(data);
298 format!("{:x}", hasher.finalize())
299}
300
301fn default_cache_path() -> Option<PathBuf> {
308 if let Ok(p) = std::env::var("SUI_EVAL_CACHE_PATH") {
309 if !p.is_empty() {
310 return Some(PathBuf::from(p));
311 }
312 }
313 dirs_next().map(|d| d.join("sui").join("eval-cache.json"))
314}
315
316fn dirs_next() -> Option<PathBuf> {
318 if let Ok(val) = std::env::var("XDG_CACHE_HOME") {
319 if !val.is_empty() {
320 return Some(PathBuf::from(val));
321 }
322 }
323 #[cfg(target_os = "macos")]
324 {
325 home_dir().map(|h| h.join("Library").join("Caches"))
326 }
327 #[cfg(not(target_os = "macos"))]
328 {
329 home_dir().map(|h| h.join(".cache"))
330 }
331}
332
333fn home_dir() -> Option<PathBuf> {
334 std::env::var("HOME").ok().map(PathBuf::from)
335}
336
337pub fn now_timestamp() -> i64 {
339 std::time::SystemTime::now()
340 .duration_since(std::time::UNIX_EPOCH)
341 .map(|d| d.as_secs() as i64)
342 .unwrap_or(0)
343}
344
345#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn cache_hit_returns_same_value() {
353 let mut cache = EvalCache::new();
354 let key = CacheKey {
355 source_hash: "abc123".to_string(),
356 lock_hash: None,
357 };
358 let value = CachedValue {
359 value_json: r#"{"type":"int","value":42}"#.to_string(),
360 timestamp: 1000,
361 };
362 cache.put(key.clone(), value.clone());
363 let got = cache.get(&key).unwrap();
364 assert_eq!(got.value_json, value.value_json);
365 }
366
367 #[test]
368 fn cache_miss_returns_none() {
369 let mut cache = EvalCache::new();
370 let key = CacheKey {
371 source_hash: "nonexistent".to_string(),
372 lock_hash: None,
373 };
374 assert!(cache.get(&key).is_none());
375 }
376
377 #[test]
378 fn different_content_different_key() {
379 let mut cache = EvalCache::new();
380 let k1 = CacheKey {
381 source_hash: sha256_hex(b"file content A"),
382 lock_hash: None,
383 };
384 let k2 = CacheKey {
385 source_hash: sha256_hex(b"file content B"),
386 lock_hash: None,
387 };
388 cache.put(
389 k1.clone(),
390 CachedValue {
391 value_json: "A".to_string(),
392 timestamp: 1,
393 },
394 );
395 assert!(cache.get(&k1).is_some());
396 assert!(cache.get(&k2).is_none());
397 }
398
399 #[test]
400 fn lock_hash_change_invalidates() {
401 let mut cache = EvalCache::new();
402 let k1 = CacheKey {
403 source_hash: "same".to_string(),
404 lock_hash: Some("lock-v1".to_string()),
405 };
406 let k2 = CacheKey {
407 source_hash: "same".to_string(),
408 lock_hash: Some("lock-v2".to_string()),
409 };
410 cache.put(
411 k1.clone(),
412 CachedValue {
413 value_json: "v1".to_string(),
414 timestamp: 1,
415 },
416 );
417 assert!(cache.get(&k1).is_some());
418 assert!(cache.get(&k2).is_none());
419 }
420
421 #[test]
422 fn disabled_cache_always_misses() {
423 let mut cache = EvalCache::disabled();
424 let key = CacheKey {
425 source_hash: "abc".to_string(),
426 lock_hash: None,
427 };
428 cache.put(
429 key.clone(),
430 CachedValue {
431 value_json: "x".to_string(),
432 timestamp: 1,
433 },
434 );
435 assert!(cache.get(&key).is_none());
436 }
437
438 #[test]
439 fn key_for_file_hashes_content() {
440 let dir = std::env::temp_dir().join("sui-eval-cache-test");
441 let _ = std::fs::create_dir_all(&dir);
442 let path = dir.join("test.nix");
443 std::fs::write(&path, "1 + 2").unwrap();
444
445 let key = EvalCache::key_for_file(&path).unwrap();
446 assert!(!key.source_hash.is_empty());
447 assert!(key.lock_hash.is_none()); let _ = std::fs::remove_file(&path);
450 let _ = std::fs::remove_dir(&dir);
451 }
452
453 #[test]
454 fn key_for_file_with_flake_lock() {
455 let dir = std::env::temp_dir().join("sui-eval-cache-test-lock");
456 let _ = std::fs::create_dir_all(&dir);
457 let path = dir.join("flake.nix");
458 let lock = dir.join("flake.lock");
459 std::fs::write(&path, "{ }").unwrap();
460 std::fs::write(&lock, r#"{"nodes":{}}"#).unwrap();
461
462 let key = EvalCache::key_for_file(&path).unwrap();
463 assert!(key.lock_hash.is_some());
464
465 let _ = std::fs::remove_file(&path);
466 let _ = std::fs::remove_file(&lock);
467 let _ = std::fs::remove_dir(&dir);
468 }
469
470 #[test]
471 fn persistent_roundtrip() {
472 let dir = std::env::temp_dir().join("sui-eval-cache-persist");
473 let _ = std::fs::create_dir_all(&dir);
474 let db = dir.join("test-cache.json");
475
476 {
478 let mut c = EvalCache::with_persistent(db.clone());
479 c.put(
480 CacheKey {
481 source_hash: "h1".to_string(),
482 lock_hash: None,
483 },
484 CachedValue {
485 value_json: r#""hello""#.to_string(),
486 timestamp: now_timestamp(),
487 },
488 );
489 assert_eq!(c.len(), 1);
490 }
491
492 {
494 let mut c = EvalCache::with_persistent(db.clone());
495 let key = CacheKey {
496 source_hash: "h1".to_string(),
497 lock_hash: None,
498 };
499 let v = c.get(&key).unwrap();
500 assert_eq!(v.value_json, r#""hello""#);
501 }
502
503 let _ = std::fs::remove_file(&db);
504 let _ = std::fs::remove_dir(&dir);
505 }
506
507 fn temp_graph_store() -> (tempfile::TempDir, GraphStore) {
510 let dir = tempfile::tempdir().unwrap();
511 let store = GraphStore::open(dir.path().to_path_buf()).unwrap();
512 (dir, store)
513 }
514
515 #[test]
516 fn graph_store_tier_round_trips_a_value() {
517 let (_dir, store) = temp_graph_store();
518 let mut cache = EvalCache::new().with_graph_store(store);
519 assert!(cache.has_graph_store());
520
521 let key = CacheKey {
522 source_hash: sha256_hex(b"some source"),
523 lock_hash: Some(sha256_hex(b"some lock")),
524 };
525 let value = CachedValue {
526 value_json: r#"{"answer":42}"#.to_string(),
527 timestamp: 1_700_000_000,
528 };
529
530 cache.put(key.clone(), value.clone());
531 let got = cache.get(&key).expect("memory tier hits");
532 assert_eq!(got.value_json, value.value_json);
533 }
534
535 #[test]
536 fn graph_store_tier_survives_fresh_cache_instance() {
537 let (_dir, store) = temp_graph_store();
538 let key = CacheKey {
539 source_hash: sha256_hex(b"persist me"),
540 lock_hash: None,
541 };
542 let value = CachedValue {
543 value_json: r#""persisted""#.to_string(),
544 timestamp: 42,
545 };
546
547 {
549 let mut c = EvalCache::new().with_graph_store(store.clone());
550 c.put(key.clone(), value.clone());
551 }
552
553 let mut c2 = EvalCache::new().with_graph_store(store);
555 let got = c2.get(&key).expect("graph_store tier hits");
556 assert_eq!(got.value_json, value.value_json);
557 let again = c2.get(&key).expect("memory promotion");
559 assert_eq!(again.value_json, value.value_json);
560 }
561
562 #[test]
563 fn graph_store_tier_isolates_by_cache_key() {
564 let (_dir, store) = temp_graph_store();
565 let mut cache = EvalCache::new().with_graph_store(store);
566
567 let k_a = CacheKey {
568 source_hash: sha256_hex(b"file a"),
569 lock_hash: None,
570 };
571 let k_b = CacheKey {
572 source_hash: sha256_hex(b"file b"),
573 lock_hash: None,
574 };
575
576 cache.put(
577 k_a.clone(),
578 CachedValue {
579 value_json: "A".to_string(),
580 timestamp: 1,
581 },
582 );
583
584 assert!(cache.get(&k_b).is_none());
587 assert!(cache.get(&k_a).is_some());
588 }
589
590 #[test]
591 fn graph_store_tier_disabled_when_cache_disabled() {
592 let (_dir, store) = temp_graph_store();
593 let mut cache = EvalCache::disabled().with_graph_store(store);
594 let key = CacheKey {
595 source_hash: "x".to_string(),
596 lock_hash: None,
597 };
598 cache.put(
599 key.clone(),
600 CachedValue {
601 value_json: "y".to_string(),
602 timestamp: 0,
603 },
604 );
605 assert!(cache.get(&key).is_none());
606 }
607
608 #[test]
609 fn all_three_tiers_stack_cleanly() {
610 let dir = tempfile::tempdir().unwrap();
611 let db_path = dir.path().join("eval-cache.json");
612 let (_gdir, store) = temp_graph_store();
613
614 let key = CacheKey {
615 source_hash: sha256_hex(b"triple-tier source"),
616 lock_hash: None,
617 };
618 let value = CachedValue {
619 value_json: r#""triple-tier""#.to_string(),
620 timestamp: 99,
621 };
622
623 {
625 let mut c = EvalCache::with_all_tiers(db_path.clone(), store.clone());
626 c.put(key.clone(), value.clone());
627 }
628
629 {
632 let mut c = EvalCache::with_persistent(db_path.clone());
633 assert!(c.get(&key).is_some(), "tier 2 (JSON) must still serve");
634 }
635
636 {
639 let mut c = EvalCache::new().with_graph_store(store);
640 assert!(c.get(&key).is_some(), "tier 3 (GraphStore) must still serve");
641 }
642 }
643
644 #[test]
645 fn sha256_hex_deterministic() {
646 let a = sha256_hex(b"hello");
647 let b = sha256_hex(b"hello");
648 assert_eq!(a, b);
649 assert_ne!(a, sha256_hex(b"world"));
650 }
651
652 #[test]
653 fn now_timestamp_reasonable() {
654 let ts = now_timestamp();
655 assert!(ts > 1_577_836_800);
657 assert!(ts < 4_102_444_800);
658 }
659
660 #[test]
661 fn len_and_is_empty() {
662 let mut cache = EvalCache::new();
663 assert!(cache.is_empty());
664 assert_eq!(cache.len(), 0);
665 cache.put(
666 CacheKey { source_hash: "x".to_string(), lock_hash: None },
667 CachedValue { value_json: "1".to_string(), timestamp: 1 },
668 );
669 assert!(!cache.is_empty());
670 assert_eq!(cache.len(), 1);
671 }
672}