1use crate::store::{FileRootSerde, Key, Payload, Store, StoreError};
20use std::collections::HashMap;
21use std::os::unix::fs::MetadataExt;
22use std::path::{Path, PathBuf};
23use std::sync::RwLock;
24
25#[derive(Debug, thiserror::Error)]
26pub enum CacheError {
27 #[error("store: {0}")]
28 Store(#[from] StoreError),
29 #[error("io: {0}")]
30 Io(#[from] std::io::Error),
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct FileRoot {
39 pub path: PathBuf,
40 pub expected_hash: String,
41}
42
43#[derive(Debug, Clone)]
44struct EntryMeta {
45 tool_kind: String,
46 file_roots: Vec<FileRoot>,
47}
48
49pub struct LiveCache {
50 store: Box<dyn Store>,
51 registry: RwLock<HashMap<String, EntryMeta>>,
52 workspace_base: PathBuf,
60}
61
62#[derive(Debug, Clone, PartialEq)]
63pub enum LookupOutcome {
64 Hit(Payload),
68 Miss,
70 Invalidated,
74}
75
76impl LiveCache {
77 pub fn new<S: Store + 'static>(store: S) -> Self {
78 let base = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
79 Self::from_box_with_workspace(Box::new(store), base)
80 }
81
82 pub fn with_workspace<S: Store + 'static>(
83 store: S,
84 workspace_base: impl Into<PathBuf>,
85 ) -> Self {
86 Self::from_box_with_workspace(Box::new(store), workspace_base.into())
87 }
88
89 pub fn from_box(store: Box<dyn Store>) -> Self {
90 let base = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
91 Self::from_box_with_workspace(store, base)
92 }
93
94 pub fn from_box_with_workspace(store: Box<dyn Store>, workspace_base: PathBuf) -> Self {
95 let mut reg = HashMap::new();
104 if let Ok(items) = store.iter_meta() {
105 for (key, meta) in items {
106 let file_roots = meta
107 .file_roots
108 .into_iter()
109 .map(|f| FileRoot {
110 path: PathBuf::from(f.path),
111 expected_hash: f.expected_hash,
112 })
113 .collect();
114 reg.insert(
115 key.0,
116 EntryMeta {
117 tool_kind: meta.tool_kind,
118 file_roots,
119 },
120 );
121 }
122 }
123 Self {
124 store,
125 registry: RwLock::new(reg),
126 workspace_base,
127 }
128 }
129
130 pub fn store(&self) -> &dyn Store {
131 self.store.as_ref()
132 }
133
134 pub fn workspace_base(&self) -> &Path {
135 &self.workspace_base
136 }
137
138 pub fn entry_count(&self) -> usize {
139 self.registry
140 .read()
141 .unwrap_or_else(|e| e.into_inner())
142 .len()
143 }
144
145 pub fn contains(&self, key: &Key) -> bool {
150 if self
151 .registry
152 .read()
153 .unwrap_or_else(|e| e.into_inner())
154 .contains_key(&key.0)
155 {
156 return true;
157 }
158 self.store.contains(key)
159 }
160
161 pub fn lookup(&self, key: &Key) -> Result<LookupOutcome, CacheError> {
166 let in_reg = self
167 .registry
168 .read()
169 .unwrap_or_else(|e| e.into_inner())
170 .contains_key(&key.0);
171 match self.store.lookup(key)? {
172 Some(p) => {
173 if !in_reg {
181 self.populate_registry_from_meta(key, &p);
182 }
183 Ok(LookupOutcome::Hit(p))
184 }
185 None => {
186 if in_reg {
187 self.registry
194 .write()
195 .unwrap_or_else(|e| e.into_inner())
196 .remove(&key.0);
197 }
198 Ok(LookupOutcome::Miss)
199 }
200 }
201 }
202
203 pub fn lookup_revalidate(&self, key: &Key) -> Result<LookupOutcome, CacheError> {
213 let cached_meta = {
217 let reg = self.registry.read().unwrap_or_else(|e| e.into_inner());
218 reg.get(&key.0).cloned()
219 };
220
221 if let Some(meta) = &cached_meta {
224 match revalidate_file_roots(&self.workspace_base, &meta.file_roots) {
225 RevalidationOutcome::Ok => {}
226 RevalidationOutcome::Invalidated => {
227 self.registry
228 .write()
229 .unwrap_or_else(|e| e.into_inner())
230 .remove(&key.0);
231 return Ok(LookupOutcome::Invalidated);
232 }
233 }
234 }
235
236 match self.store.lookup(key)? {
237 Some(p) => {
238 if cached_meta.is_none() {
247 let local_roots: Vec<FileRoot> = p
248 .meta
249 .file_roots
250 .iter()
251 .map(|f| FileRoot {
252 path: PathBuf::from(&f.path),
253 expected_hash: f.expected_hash.clone(),
254 })
255 .collect();
256 match revalidate_file_roots(&self.workspace_base, &local_roots) {
257 RevalidationOutcome::Ok => {
258 self.populate_registry_from_meta(key, &p);
259 }
260 RevalidationOutcome::Invalidated => {
261 return Ok(LookupOutcome::Invalidated);
262 }
263 }
264 }
265 Ok(LookupOutcome::Hit(p))
266 }
267 None => {
268 if cached_meta.is_some() {
269 self.registry
270 .write()
271 .unwrap_or_else(|e| e.into_inner())
272 .remove(&key.0);
273 }
274 Ok(LookupOutcome::Miss)
275 }
276 }
277 }
278
279 fn populate_registry_from_meta(&self, key: &Key, p: &Payload) {
280 let file_roots = p
281 .meta
282 .file_roots
283 .iter()
284 .map(|f| FileRoot {
285 path: PathBuf::from(&f.path),
286 expected_hash: f.expected_hash.clone(),
287 })
288 .collect();
289 self.registry
290 .write()
291 .unwrap_or_else(|e| e.into_inner())
292 .insert(
293 key.0.clone(),
294 EntryMeta {
295 tool_kind: p.meta.tool_kind.clone(),
296 file_roots,
297 },
298 );
299 }
300
301 pub fn persist(
305 &self,
306 key: &Key,
307 bytes: &[u8],
308 tool_kind: &str,
309 file_roots: Vec<FileRoot>,
310 ) -> Result<(), CacheError> {
311 self.persist_with_upstreams(key, bytes, tool_kind, file_roots, Vec::new())
312 }
313
314 pub fn persist_with_upstreams(
319 &self,
320 key: &Key,
321 bytes: &[u8],
322 tool_kind: &str,
323 file_roots: Vec<FileRoot>,
324 upstream_keys: Vec<Key>,
325 ) -> Result<(), CacheError> {
326 let serde_roots: Vec<FileRootSerde> = file_roots
327 .iter()
328 .map(|r| FileRootSerde {
329 path: r.path.display().to_string(),
330 expected_hash: r.expected_hash.clone(),
331 })
332 .collect();
333 let upstream_strings: Vec<String> = upstream_keys.iter().map(|k| k.0.clone()).collect();
334 self.store
335 .persist_with_upstreams(key, bytes, tool_kind, serde_roots, upstream_strings)?;
336 self.registry
337 .write()
338 .unwrap_or_else(|e| e.into_inner())
339 .insert(
340 key.0.clone(),
341 EntryMeta {
342 tool_kind: tool_kind.to_string(),
343 file_roots,
344 },
345 );
346 Ok(())
347 }
348
349 pub fn mark_dirty(&self, key: &Key) {
357 self.registry
358 .write()
359 .unwrap_or_else(|e| e.into_inner())
360 .remove(&key.0);
361 let _ = self.store.remove(key);
366 }
367
368 pub fn invalidate_upstream(&self, upstream_key: &Key) -> usize {
381 let metas = match self.store.iter_meta() {
387 Ok(m) => m,
388 Err(_) => return 0,
389 };
390 let mut dirty: std::collections::HashSet<String> =
391 std::collections::HashSet::from([upstream_key.0.clone()]);
392 loop {
393 let before = dirty.len();
394 for (k, meta) in &metas {
395 if dirty.contains(&k.0) {
396 continue;
397 }
398 if meta.upstream_keys.iter().any(|u| dirty.contains(u)) {
399 dirty.insert(k.0.clone());
400 }
401 }
402 if dirty.len() == before {
403 break;
404 }
405 }
406 let mut reg = self.registry.write().unwrap_or_else(|e| e.into_inner());
407 let mut dropped = 0;
408 for k in &dirty {
409 if k == &upstream_key.0 {
410 continue;
411 }
412 reg.remove(k);
413 if self.store.remove(&Key(k.clone())).is_ok() {
414 dropped += 1;
415 }
416 }
417 dropped
418 }
419
420 pub fn invalidate_path(&self, path: &Path) -> usize {
425 let target = match path.canonicalize() {
426 Ok(p) => p,
427 Err(_) => path.to_path_buf(),
428 };
429 let target_ci = lower_path(&target);
436 let path_ci = lower_path(path);
437 let metas = match self.store.iter_meta() {
438 Ok(m) => m,
439 Err(_) => return 0,
440 };
441 let to_drop: Vec<String> = metas
442 .iter()
443 .filter_map(|(k, meta)| {
444 let touches = meta.file_roots.iter().any(|r| {
445 let recorded = PathBuf::from(&r.path);
446 let resolved = resolve_root_path(&self.workspace_base, &recorded);
447 let resolved_ci = lower_path(&resolved);
448 match resolved.canonicalize() {
449 Ok(c) => lower_path(&c) == target_ci,
450 Err(_) => resolved_ci == path_ci || lower_path(&recorded) == path_ci,
451 }
452 });
453 if touches {
454 Some(k.0.clone())
455 } else {
456 None
457 }
458 })
459 .collect();
460 let n = to_drop.len();
461 for k in to_drop {
462 let key = Key(k);
463 self.invalidate_upstream(&key);
466 self.registry
467 .write()
468 .unwrap_or_else(|e| e.into_inner())
469 .remove(&key.0);
470 let _ = self.store.remove(&key);
471 }
472 n
473 }
474
475 pub fn known_kinds(&self) -> Vec<String> {
476 let reg = self.registry.read().unwrap_or_else(|e| e.into_inner());
477 let mut kinds: Vec<String> = reg.values().map(|m| m.tool_kind.clone()).collect();
478 kinds.sort();
479 kinds.dedup();
480 kinds
481 }
482}
483
484enum RevalidationOutcome {
488 Ok,
489 Invalidated,
490}
491
492pub fn current_root_fingerprint(path: &Path, expected: &str) -> std::io::Result<String> {
496 if expected.starts_with(DIR_PREFIX) {
497 fingerprint_dir(path)
498 } else if expected.starts_with(STAT_PREFIX) {
499 stat_fingerprint(path)
500 } else {
501 hash_file(path)
502 }
503}
504
505fn revalidate_file_roots(workspace_base: &Path, roots: &[FileRoot]) -> RevalidationOutcome {
506 let debug = std::env::var_os("VERDANT_DEBUG_INVALIDATION").is_some();
507 for root in roots {
508 let resolved = resolve_root_path(workspace_base, &root.path);
509 let current = match current_root_fingerprint(&resolved, &root.expected_hash) {
510 Ok(c) => c,
511 Err(_) => {
512 if debug {
513 eprintln!(
514 "verdant: invalidated by missing/unreadable {}",
515 resolved.display()
516 );
517 }
518 return RevalidationOutcome::Invalidated;
519 }
520 };
521 if current != root.expected_hash {
522 if debug {
523 eprintln!("verdant: invalidated by changed {}", resolved.display());
524 }
525 return RevalidationOutcome::Invalidated;
526 }
527 }
528 RevalidationOutcome::Ok
529}
530
531fn resolve_root_path(workspace_base: &Path, recorded: &Path) -> PathBuf {
539 workspace_base.join(recorded)
540}
541
542fn lower_path(p: &Path) -> String {
545 p.to_string_lossy().to_lowercase()
546}
547
548const HASH_MAX_BYTES: u64 = 100 * 1024 * 1024;
553
554pub fn hash_max_bytes() -> u64 {
557 std::env::var("VERDANT_HASH_MAX_BYTES")
558 .ok()
559 .and_then(|s| s.parse::<u64>().ok())
560 .unwrap_or(HASH_MAX_BYTES)
561}
562
563#[derive(Debug, Clone, PartialEq, Eq)]
565pub enum FileHash {
566 Content(String),
568 Oversized,
573}
574
575impl FileHash {
576 pub fn content(&self) -> Option<&str> {
577 match self {
578 FileHash::Content(h) => Some(h),
579 FileHash::Oversized => None,
580 }
581 }
582}
583
584pub fn hash_file(path: &Path) -> std::io::Result<String> {
588 let mut hasher = blake3::Hasher::new();
589 let mut f = std::fs::File::open(path)?;
590 let mut buf = [0u8; 1 << 16];
591 loop {
592 let n = std::io::Read::read(&mut f, &mut buf)?;
593 if n == 0 {
594 break;
595 }
596 hasher.update(&buf[..n]);
597 }
598 Ok(hasher.finalize().to_hex().to_string())
599}
600
601pub fn hash_file_with_limit(path: &Path, max: u64) -> std::io::Result<FileHash> {
605 if std::fs::metadata(path)?.len() > max {
606 return Ok(FileHash::Oversized);
607 }
608 Ok(FileHash::Content(hash_file(path)?))
609}
610
611pub fn tool_result_key(content: &[u8]) -> Key {
618 let mut framed = Vec::with_capacity(content.len() + 12);
619 framed.extend_from_slice(b"tool_result\0");
620 framed.extend_from_slice(content);
621 Key::from_bytes(&framed)
622}
623
624pub const STAT_PREFIX: &str = "stat:";
625pub const DIR_PREFIX: &str = "dir:";
626
627pub fn fingerprint_dir(path: &Path) -> std::io::Result<String> {
635 let mut names: Vec<Vec<u8>> = Vec::new();
636 for entry in std::fs::read_dir(path)? {
637 names.push(entry?.file_name().as_encoded_bytes().to_vec());
638 }
639 names.sort();
640 let mut hasher = blake3::Hasher::new();
641 for name in &names {
642 hasher.update(&(name.len() as u64).to_le_bytes());
643 hasher.update(name);
644 }
645 Ok(format!("{DIR_PREFIX}{}", hasher.finalize().to_hex()))
646}
647
648pub fn stat_fingerprint(path: &Path) -> std::io::Result<String> {
654 let m = std::fs::metadata(path)?;
655 Ok(format!(
656 "{STAT_PREFIX}{}:{}:{}",
657 m.len(),
658 m.mtime(),
659 m.mtime_nsec()
660 ))
661}
662
663pub fn fingerprint_file(path: &Path, content_max: u64) -> std::io::Result<String> {
670 if std::fs::metadata(path)?.len() > content_max {
671 stat_fingerprint(path)
672 } else {
673 hash_file(path)
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use tempfile::TempDir;
681
682 fn cache(dir: &TempDir) -> LiveCache {
683 let store = crate::store::FileStore::open(dir.path().join("store")).unwrap();
684 LiveCache::new(store)
685 }
686
687 fn write_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf {
688 let p = dir.path().join(name);
689 std::fs::write(&p, content).unwrap();
690 p
691 }
692
693 fn root_for(p: &Path) -> FileRoot {
694 FileRoot {
695 path: p.to_path_buf(),
696 expected_hash: hash_file(p).unwrap(),
697 }
698 }
699
700 #[test]
701 fn miss_then_persist_then_hit() {
702 let dir = TempDir::new().unwrap();
703 let cache = cache(&dir);
704 let p = write_file(&dir, "a.txt", b"alpha");
705 let key = Key::from_bytes(b"read|a.txt|alpha");
706
707 assert_eq!(cache.lookup(&key).unwrap(), LookupOutcome::Miss);
708
709 cache
710 .persist(&key, b"alpha-formatted", "read", vec![root_for(&p)])
711 .unwrap();
712
713 match cache.lookup(&key).unwrap() {
714 LookupOutcome::Hit(payload) => {
715 assert_eq!(payload.bytes, b"alpha-formatted");
716 assert_eq!(payload.meta.tool_kind, "read");
717 }
718 other => panic!("expected Hit, got {other:?}"),
719 }
720 }
721
722 #[test]
723 fn revalidate_unchanged_returns_hit() {
724 let dir = TempDir::new().unwrap();
725 let cache = cache(&dir);
726 let p = write_file(&dir, "b.txt", b"beta");
727 let key = Key::from_bytes(b"read|b.txt|beta");
728 cache
729 .persist(&key, b"beta-formatted", "read", vec![root_for(&p)])
730 .unwrap();
731 match cache.lookup_revalidate(&key).unwrap() {
732 LookupOutcome::Hit(_) => {}
733 other => panic!("expected Hit, got {other:?}"),
734 }
735 }
736
737 #[test]
738 fn dir_root_revalidates_on_listing_and_invalidates_on_new_entry() {
739 let dir = TempDir::new().unwrap();
740 let cache = cache(&dir);
741 let listed = dir.path().join("listed");
742 std::fs::create_dir(&listed).unwrap();
743 write_file(&dir, "listed/a.txt", b"alpha");
744 let key = Key::from_bytes(b"exec|ls listed");
745 let root = FileRoot {
746 path: listed.clone(),
747 expected_hash: fingerprint_dir(&listed).unwrap(),
748 };
749 cache.persist(&key, b"a.txt\n", "exec", vec![root]).unwrap();
750
751 match cache.lookup_revalidate(&key).unwrap() {
752 LookupOutcome::Hit(_) => {}
753 other => panic!("unchanged listing must hit, got {other:?}"),
754 }
755
756 write_file(&dir, "listed/b.txt", b"bravo");
759 match cache.lookup_revalidate(&key).unwrap() {
760 LookupOutcome::Invalidated => {}
761 other => panic!("a new entry must invalidate, got {other:?}"),
762 }
763 }
764
765 #[test]
766 fn dir_root_of_a_removed_directory_invalidates() {
767 let dir = TempDir::new().unwrap();
768 let cache = cache(&dir);
769 let listed = dir.path().join("gone");
770 std::fs::create_dir(&listed).unwrap();
771 let key = Key::from_bytes(b"exec|ls gone");
772 let root = FileRoot {
773 path: listed.clone(),
774 expected_hash: fingerprint_dir(&listed).unwrap(),
775 };
776 cache.persist(&key, b"", "exec", vec![root]).unwrap();
777 std::fs::remove_dir(&listed).unwrap();
778 match cache.lookup_revalidate(&key).unwrap() {
779 LookupOutcome::Invalidated => {}
780 other => panic!("a vanished dir must invalidate, got {other:?}"),
781 }
782 }
783
784 #[test]
785 fn revalidate_modified_invalidates() {
786 let dir = TempDir::new().unwrap();
787 let cache = cache(&dir);
788 let p = write_file(&dir, "c.txt", b"charlie");
789 let key = Key::from_bytes(b"read|c.txt|charlie");
790 cache
791 .persist(&key, b"charlie-formatted", "read", vec![root_for(&p)])
792 .unwrap();
793
794 std::fs::write(&p, b"DELTA").unwrap();
795
796 match cache.lookup_revalidate(&key).unwrap() {
797 LookupOutcome::Invalidated => {}
798 other => panic!("expected Invalidated, got {other:?}"),
799 }
800 assert_eq!(cache.entry_count(), 0);
801 }
802
803 #[test]
804 fn revalidate_deleted_invalidates() {
805 let dir = TempDir::new().unwrap();
806 let cache = cache(&dir);
807 let p = write_file(&dir, "d.txt", b"delta");
808 let key = Key::from_bytes(b"read|d.txt|delta");
809 cache
810 .persist(&key, b"delta-formatted", "read", vec![root_for(&p)])
811 .unwrap();
812
813 std::fs::remove_file(&p).unwrap();
814
815 match cache.lookup_revalidate(&key).unwrap() {
816 LookupOutcome::Invalidated => {}
817 other => panic!("expected Invalidated, got {other:?}"),
818 }
819 }
820
821 #[test]
822 fn mark_dirty_drops_entry() {
823 let dir = TempDir::new().unwrap();
824 let cache = cache(&dir);
825 let p = write_file(&dir, "e.txt", b"echo");
826 let key = Key::from_bytes(b"read|e.txt|echo");
827 cache
828 .persist(&key, b"echo-formatted", "read", vec![root_for(&p)])
829 .unwrap();
830 assert_eq!(cache.entry_count(), 1);
831 cache.mark_dirty(&key);
832 assert_eq!(cache.entry_count(), 0);
833 assert_eq!(cache.lookup(&key).unwrap(), LookupOutcome::Miss);
834 }
835
836 #[test]
837 fn invalidate_path_drops_matching_entries() {
838 let dir = TempDir::new().unwrap();
839 let cache = cache(&dir);
840 let p1 = write_file(&dir, "f1.txt", b"foxtrot");
841 let p2 = write_file(&dir, "f2.txt", b"foxtrot2");
842 let k1 = Key::from_bytes(b"read|f1");
843 let k2 = Key::from_bytes(b"read|f2");
844 cache
845 .persist(&k1, b"f1-out", "read", vec![root_for(&p1)])
846 .unwrap();
847 cache
848 .persist(&k2, b"f2-out", "read", vec![root_for(&p2)])
849 .unwrap();
850 assert_eq!(cache.entry_count(), 2);
851 let n = cache.invalidate_path(&p1);
852 assert_eq!(n, 1);
853 assert_eq!(cache.entry_count(), 1);
854 match cache.lookup(&k2).unwrap() {
856 LookupOutcome::Hit(_) => {}
857 other => panic!("k2 should still hit, got {other:?}"),
858 }
859 match cache.lookup(&k1).unwrap() {
860 LookupOutcome::Miss => {}
861 other => panic!("k1 should miss, got {other:?}"),
862 }
863 }
864
865 #[test]
866 fn invalidate_path_matches_case_insensitively() {
867 let dir = TempDir::new().unwrap();
872 let cache = cache(&dir);
873 let p = write_file(&dir, "CaseFile.txt", b"contents");
874 let key = Key::from_bytes(b"read|casefile");
875 cache
876 .persist(&key, b"formatted", "read", vec![root_for(&p)])
877 .unwrap();
878 assert_eq!(cache.entry_count(), 1);
879
880 let differently_cased = dir.path().join("casefile.txt");
881 let n = cache.invalidate_path(&differently_cased);
882 assert_eq!(n, 1, "case-differing path must still invalidate the entry");
883 assert_eq!(cache.entry_count(), 0);
884 }
885
886 #[test]
887 fn multi_root_revalidation() {
888 let dir = TempDir::new().unwrap();
889 let cache = cache(&dir);
890 let p1 = write_file(&dir, "g1.txt", b"golf1");
891 let p2 = write_file(&dir, "g2.txt", b"golf2");
892 let key = Key::from_bytes(b"grep|pattern|g1+g2");
893 cache
894 .persist(
895 &key,
896 b"merged-output",
897 "grep",
898 vec![root_for(&p1), root_for(&p2)],
899 )
900 .unwrap();
901
902 match cache.lookup_revalidate(&key).unwrap() {
904 LookupOutcome::Hit(_) => {}
905 other => panic!("expected Hit, got {other:?}"),
906 }
907 std::fs::write(&p2, b"changed").unwrap();
909 match cache.lookup_revalidate(&key).unwrap() {
910 LookupOutcome::Invalidated => {}
911 other => panic!("expected Invalidated, got {other:?}"),
912 }
913 }
914
915 #[test]
916 fn upstream_invalidation_drops_dependents() {
917 let dir = TempDir::new().unwrap();
918 let cache = cache(&dir);
919 let p = write_file(&dir, "src.txt", b"alpha");
920 let read_key = Key::from_bytes(b"read|src");
921 cache
922 .persist(&read_key, b"alpha-formatted", "read", vec![root_for(&p)])
923 .unwrap();
924 let llm1 = Key::from_bytes(b"llm|first-prompt");
926 let llm2 = Key::from_bytes(b"llm|second-prompt");
927 cache
928 .persist_with_upstreams(
929 &llm1,
930 b"completion-1",
931 "llm_call",
932 vec![],
933 vec![read_key.clone()],
934 )
935 .unwrap();
936 cache
937 .persist_with_upstreams(
938 &llm2,
939 b"completion-2",
940 "llm_call",
941 vec![],
942 vec![read_key.clone()],
943 )
944 .unwrap();
945 assert_eq!(cache.entry_count(), 3);
946
947 let dropped = cache.invalidate_upstream(&read_key);
949 assert_eq!(dropped, 2);
950 assert_eq!(cache.lookup(&llm1).unwrap(), LookupOutcome::Miss);
951 assert_eq!(cache.lookup(&llm2).unwrap(), LookupOutcome::Miss);
952 }
953
954 #[test]
955 fn invalidate_path_cascades_to_dependent_llm_calls() {
956 let dir = TempDir::new().unwrap();
957 let cache = cache(&dir);
958 let p = write_file(&dir, "input.txt", b"hello");
959 let read_key = Key::from_bytes(b"read|input");
960 cache
961 .persist(&read_key, b"hello-formatted", "read", vec![root_for(&p)])
962 .unwrap();
963 let llm = Key::from_bytes(b"llm|sees-read");
964 cache
965 .persist_with_upstreams(
966 &llm,
967 b"completion",
968 "llm_call",
969 vec![],
970 vec![read_key.clone()],
971 )
972 .unwrap();
973 assert_eq!(cache.entry_count(), 2);
974
975 std::fs::write(&p, b"changed").unwrap();
977 let n = cache.invalidate_path(&p);
978 assert_eq!(n, 1, "the read entry was the direct path match");
979 assert_eq!(cache.lookup(&llm).unwrap(), LookupOutcome::Miss);
981 assert_eq!(cache.entry_count(), 0);
982 }
983
984 #[test]
985 fn transitive_invalidation_walks_multi_hop_chain() {
986 let dir = TempDir::new().unwrap();
988 let cache = cache(&dir);
989 let key_a = Key::from_bytes(b"a");
990 let key_b = Key::from_bytes(b"b");
991 let key_c = Key::from_bytes(b"c");
992 let p = write_file(&dir, "f.txt", b"x");
993 cache
994 .persist(&key_a, b"a-bytes", "read", vec![root_for(&p)])
995 .unwrap();
996 cache
997 .persist_with_upstreams(&key_b, b"b-bytes", "llm_call", vec![], vec![key_a.clone()])
998 .unwrap();
999 cache
1000 .persist_with_upstreams(&key_c, b"c-bytes", "llm_call", vec![], vec![key_b.clone()])
1001 .unwrap();
1002
1003 let dropped = cache.invalidate_upstream(&key_a);
1004 assert_eq!(dropped, 2);
1005 assert_eq!(cache.lookup(&key_b).unwrap(), LookupOutcome::Miss);
1006 assert_eq!(cache.lookup(&key_c).unwrap(), LookupOutcome::Miss);
1007 }
1008
1009 #[test]
1010 fn upstream_keys_persist_across_rehydration() {
1011 let dir = TempDir::new().unwrap();
1012 let p = write_file(&dir, "g.txt", b"data");
1013 let read_key = Key::from_bytes(b"read|g");
1014 let llm_key = Key::from_bytes(b"llm|g-consumer");
1015
1016 {
1017 let cache = cache(&dir);
1018 cache
1019 .persist(&read_key, b"data-formatted", "read", vec![root_for(&p)])
1020 .unwrap();
1021 cache
1022 .persist_with_upstreams(
1023 &llm_key,
1024 b"completion",
1025 "llm_call",
1026 vec![],
1027 vec![read_key.clone()],
1028 )
1029 .unwrap();
1030 }
1031
1032 let store_root = dir.path().join("store");
1035 let store2 = crate::store::FileStore::open(store_root).unwrap();
1036 let cache2 = LiveCache::new(store2);
1037 assert_eq!(cache2.entry_count(), 2);
1038 let dropped = cache2.invalidate_upstream(&read_key);
1039 assert_eq!(dropped, 1, "rehydrated edge must support cascade");
1040 }
1041
1042 #[test]
1043 fn cross_instance_file_edit_cascades_tool_and_llm() {
1044 let dir = TempDir::new().unwrap();
1050 let f = write_file(&dir, "dep.txt", b"v1");
1051 let content = b"TOOL: contents of dep.txt";
1052 let tkey = tool_result_key(content);
1053 let llm_key = Key::from_bytes(b"llm|consumed-the-tool-result");
1054
1055 {
1056 let producer = cache(&dir);
1057 producer
1058 .persist(&tkey, content, "tool_result", vec![root_for(&f)])
1059 .unwrap();
1060 producer
1061 .persist_with_upstreams(
1062 &llm_key,
1063 b"completion-bytes",
1064 "llm_call",
1065 vec![],
1066 vec![tkey.clone()],
1067 )
1068 .unwrap();
1069 }
1070
1071 {
1072 let editor = cache(&dir);
1073 std::fs::write(&f, b"v2-changed").unwrap();
1074 let n = editor.invalidate_path(&f);
1075 assert!(
1076 n >= 1,
1077 "the tool node depending on the file must be dropped"
1078 );
1079 }
1080
1081 let reader = cache(&dir);
1082 assert!(
1083 matches!(reader.lookup(&tkey).unwrap(), LookupOutcome::Miss),
1084 "tool result node must be gone cross-instance"
1085 );
1086 assert!(
1087 matches!(reader.lookup(&llm_key).unwrap(), LookupOutcome::Miss),
1088 "the dependent LLM completion must be gone cross-instance"
1089 );
1090 }
1091
1092 #[test]
1093 fn fresh_cache_rehydrates_from_store_on_disk() {
1094 let dir = TempDir::new().unwrap();
1099 let p = write_file(&dir, "rehydrate.txt", b"persist me");
1100 let key = Key::from_bytes(b"read|rehydrate|persist me");
1101
1102 {
1103 let cache = cache(&dir);
1104 cache
1105 .persist(&key, b"served-once", "read", vec![root_for(&p)])
1106 .unwrap();
1107 assert_eq!(cache.entry_count(), 1);
1108 } let store_root = dir.path().join("store");
1111 let store2 = crate::store::FileStore::open(store_root).unwrap();
1112 let cache2 = LiveCache::new(store2);
1113 assert_eq!(cache2.entry_count(), 1);
1116 match cache2.lookup_revalidate(&key).unwrap() {
1117 LookupOutcome::Hit(payload) => assert_eq!(payload.bytes, b"served-once"),
1118 other => panic!("expected Hit after rehydrate, got {other:?}"),
1119 }
1120 }
1121
1122 #[test]
1123 fn hit_returns_byte_identical_payload() {
1124 let dir = TempDir::new().unwrap();
1128 let cache = cache(&dir);
1129 let p = write_file(&dir, "h.txt", b"hotel");
1130 let key = Key::from_bytes(b"read|h");
1131 let original = b" 1\thotel-formatted-with-line-numbers\n 2\tetc\n";
1132 cache
1133 .persist(&key, original, "read", vec![root_for(&p)])
1134 .unwrap();
1135 match cache.lookup_revalidate(&key).unwrap() {
1136 LookupOutcome::Hit(p) => assert_eq!(p.bytes, original),
1137 other => panic!("expected Hit, got {other:?}"),
1138 }
1139 }
1140
1141 #[test]
1142 fn hash_file_with_limit_content_hashes_within_limit() {
1143 let dir = TempDir::new().unwrap();
1144 let p = write_file(&dir, "small.bin", b"comfortably within the limit");
1145 match hash_file_with_limit(&p, 1024).unwrap() {
1146 FileHash::Content(h) => assert_eq!(h, hash_file(&p).unwrap()),
1147 FileHash::Oversized => panic!("a file within the limit must content-hash"),
1148 }
1149 }
1150
1151 #[test]
1152 fn hash_file_with_limit_reports_oversized_above_limit() {
1153 let dir = TempDir::new().unwrap();
1154 let p = write_file(&dir, "big.bin", &[7u8; 4096]);
1155 assert_eq!(hash_file_with_limit(&p, 64).unwrap(), FileHash::Oversized);
1156 }
1157
1158 #[test]
1159 fn oversized_files_yield_no_keyable_digest() {
1160 let dir = TempDir::new().unwrap();
1165 let a = write_file(&dir, "a.bin", &[1u8; 4096]);
1166 let b = write_file(&dir, "b.bin", &[2u8; 4096]);
1167 let ha = hash_file_with_limit(&a, 64).unwrap();
1168 let hb = hash_file_with_limit(&b, 64).unwrap();
1169 assert_eq!(ha, FileHash::Oversized);
1170 assert_eq!(hb, FileHash::Oversized);
1171 assert!(ha.content().is_none() && hb.content().is_none());
1172 }
1173}