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
492fn revalidate_file_roots(workspace_base: &Path, roots: &[FileRoot]) -> RevalidationOutcome {
493 let debug = std::env::var_os("VERDANT_DEBUG_INVALIDATION").is_some();
494 for root in roots {
495 let resolved = resolve_root_path(workspace_base, &root.path);
496 let current = if root.expected_hash.starts_with(DIR_PREFIX) {
500 match fingerprint_dir(&resolved) {
501 Ok(d) => d,
502 Err(_) => {
503 if debug {
504 eprintln!(
505 "verdant: invalidated by missing/unlistable dir {}",
506 resolved.display()
507 );
508 }
509 return RevalidationOutcome::Invalidated;
510 }
511 }
512 } else if root.expected_hash.starts_with(STAT_PREFIX) {
513 match stat_fingerprint(&resolved) {
514 Ok(s) => s,
515 Err(_) => {
516 if debug {
517 eprintln!(
518 "verdant: invalidated by missing/unreadable {}",
519 resolved.display()
520 );
521 }
522 return RevalidationOutcome::Invalidated;
523 }
524 }
525 } else {
526 match hash_file(&resolved) {
527 Ok(h) => h,
528 Err(_) => {
529 if debug {
530 eprintln!(
531 "verdant: invalidated by missing/unreadable {}",
532 resolved.display()
533 );
534 }
535 return RevalidationOutcome::Invalidated;
536 }
537 }
538 };
539 if current != root.expected_hash {
540 if debug {
541 eprintln!("verdant: invalidated by changed {}", resolved.display());
542 }
543 return RevalidationOutcome::Invalidated;
544 }
545 }
546 RevalidationOutcome::Ok
547}
548
549fn resolve_root_path(workspace_base: &Path, recorded: &Path) -> PathBuf {
557 workspace_base.join(recorded)
558}
559
560fn lower_path(p: &Path) -> String {
563 p.to_string_lossy().to_lowercase()
564}
565
566const HASH_MAX_BYTES: u64 = 100 * 1024 * 1024;
571
572pub fn hash_max_bytes() -> u64 {
575 std::env::var("VERDANT_HASH_MAX_BYTES")
576 .ok()
577 .and_then(|s| s.parse::<u64>().ok())
578 .unwrap_or(HASH_MAX_BYTES)
579}
580
581#[derive(Debug, Clone, PartialEq, Eq)]
583pub enum FileHash {
584 Content(String),
586 Oversized,
591}
592
593impl FileHash {
594 pub fn content(&self) -> Option<&str> {
595 match self {
596 FileHash::Content(h) => Some(h),
597 FileHash::Oversized => None,
598 }
599 }
600}
601
602pub fn hash_file(path: &Path) -> std::io::Result<String> {
606 let mut hasher = blake3::Hasher::new();
607 let mut f = std::fs::File::open(path)?;
608 let mut buf = [0u8; 1 << 16];
609 loop {
610 let n = std::io::Read::read(&mut f, &mut buf)?;
611 if n == 0 {
612 break;
613 }
614 hasher.update(&buf[..n]);
615 }
616 Ok(hasher.finalize().to_hex().to_string())
617}
618
619pub fn hash_file_with_limit(path: &Path, max: u64) -> std::io::Result<FileHash> {
623 if std::fs::metadata(path)?.len() > max {
624 return Ok(FileHash::Oversized);
625 }
626 Ok(FileHash::Content(hash_file(path)?))
627}
628
629pub fn tool_result_key(content: &[u8]) -> Key {
636 let mut framed = Vec::with_capacity(content.len() + 12);
637 framed.extend_from_slice(b"tool_result\0");
638 framed.extend_from_slice(content);
639 Key::from_bytes(&framed)
640}
641
642pub const STAT_PREFIX: &str = "stat:";
643pub const DIR_PREFIX: &str = "dir:";
644
645pub fn fingerprint_dir(path: &Path) -> std::io::Result<String> {
653 let mut names: Vec<Vec<u8>> = Vec::new();
654 for entry in std::fs::read_dir(path)? {
655 names.push(entry?.file_name().as_encoded_bytes().to_vec());
656 }
657 names.sort();
658 let mut hasher = blake3::Hasher::new();
659 for name in &names {
660 hasher.update(&(name.len() as u64).to_le_bytes());
661 hasher.update(name);
662 }
663 Ok(format!("{DIR_PREFIX}{}", hasher.finalize().to_hex()))
664}
665
666pub fn stat_fingerprint(path: &Path) -> std::io::Result<String> {
672 let m = std::fs::metadata(path)?;
673 Ok(format!(
674 "{STAT_PREFIX}{}:{}:{}",
675 m.len(),
676 m.mtime(),
677 m.mtime_nsec()
678 ))
679}
680
681pub fn fingerprint_file(path: &Path, content_max: u64) -> std::io::Result<String> {
688 if std::fs::metadata(path)?.len() > content_max {
689 stat_fingerprint(path)
690 } else {
691 hash_file(path)
692 }
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use tempfile::TempDir;
699
700 fn cache(dir: &TempDir) -> LiveCache {
701 let store = crate::store::FileStore::open(dir.path().join("store")).unwrap();
702 LiveCache::new(store)
703 }
704
705 fn write_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf {
706 let p = dir.path().join(name);
707 std::fs::write(&p, content).unwrap();
708 p
709 }
710
711 fn root_for(p: &Path) -> FileRoot {
712 FileRoot {
713 path: p.to_path_buf(),
714 expected_hash: hash_file(p).unwrap(),
715 }
716 }
717
718 #[test]
719 fn miss_then_persist_then_hit() {
720 let dir = TempDir::new().unwrap();
721 let cache = cache(&dir);
722 let p = write_file(&dir, "a.txt", b"alpha");
723 let key = Key::from_bytes(b"read|a.txt|alpha");
724
725 assert_eq!(cache.lookup(&key).unwrap(), LookupOutcome::Miss);
726
727 cache
728 .persist(&key, b"alpha-formatted", "read", vec![root_for(&p)])
729 .unwrap();
730
731 match cache.lookup(&key).unwrap() {
732 LookupOutcome::Hit(payload) => {
733 assert_eq!(payload.bytes, b"alpha-formatted");
734 assert_eq!(payload.meta.tool_kind, "read");
735 }
736 other => panic!("expected Hit, got {other:?}"),
737 }
738 }
739
740 #[test]
741 fn revalidate_unchanged_returns_hit() {
742 let dir = TempDir::new().unwrap();
743 let cache = cache(&dir);
744 let p = write_file(&dir, "b.txt", b"beta");
745 let key = Key::from_bytes(b"read|b.txt|beta");
746 cache
747 .persist(&key, b"beta-formatted", "read", vec![root_for(&p)])
748 .unwrap();
749 match cache.lookup_revalidate(&key).unwrap() {
750 LookupOutcome::Hit(_) => {}
751 other => panic!("expected Hit, got {other:?}"),
752 }
753 }
754
755 #[test]
756 fn dir_root_revalidates_on_listing_and_invalidates_on_new_entry() {
757 let dir = TempDir::new().unwrap();
758 let cache = cache(&dir);
759 let listed = dir.path().join("listed");
760 std::fs::create_dir(&listed).unwrap();
761 write_file(&dir, "listed/a.txt", b"alpha");
762 let key = Key::from_bytes(b"exec|ls listed");
763 let root = FileRoot {
764 path: listed.clone(),
765 expected_hash: fingerprint_dir(&listed).unwrap(),
766 };
767 cache.persist(&key, b"a.txt\n", "exec", vec![root]).unwrap();
768
769 match cache.lookup_revalidate(&key).unwrap() {
770 LookupOutcome::Hit(_) => {}
771 other => panic!("unchanged listing must hit, got {other:?}"),
772 }
773
774 write_file(&dir, "listed/b.txt", b"bravo");
777 match cache.lookup_revalidate(&key).unwrap() {
778 LookupOutcome::Invalidated => {}
779 other => panic!("a new entry must invalidate, got {other:?}"),
780 }
781 }
782
783 #[test]
784 fn dir_root_of_a_removed_directory_invalidates() {
785 let dir = TempDir::new().unwrap();
786 let cache = cache(&dir);
787 let listed = dir.path().join("gone");
788 std::fs::create_dir(&listed).unwrap();
789 let key = Key::from_bytes(b"exec|ls gone");
790 let root = FileRoot {
791 path: listed.clone(),
792 expected_hash: fingerprint_dir(&listed).unwrap(),
793 };
794 cache.persist(&key, b"", "exec", vec![root]).unwrap();
795 std::fs::remove_dir(&listed).unwrap();
796 match cache.lookup_revalidate(&key).unwrap() {
797 LookupOutcome::Invalidated => {}
798 other => panic!("a vanished dir must invalidate, got {other:?}"),
799 }
800 }
801
802 #[test]
803 fn revalidate_modified_invalidates() {
804 let dir = TempDir::new().unwrap();
805 let cache = cache(&dir);
806 let p = write_file(&dir, "c.txt", b"charlie");
807 let key = Key::from_bytes(b"read|c.txt|charlie");
808 cache
809 .persist(&key, b"charlie-formatted", "read", vec![root_for(&p)])
810 .unwrap();
811
812 std::fs::write(&p, b"DELTA").unwrap();
813
814 match cache.lookup_revalidate(&key).unwrap() {
815 LookupOutcome::Invalidated => {}
816 other => panic!("expected Invalidated, got {other:?}"),
817 }
818 assert_eq!(cache.entry_count(), 0);
819 }
820
821 #[test]
822 fn revalidate_deleted_invalidates() {
823 let dir = TempDir::new().unwrap();
824 let cache = cache(&dir);
825 let p = write_file(&dir, "d.txt", b"delta");
826 let key = Key::from_bytes(b"read|d.txt|delta");
827 cache
828 .persist(&key, b"delta-formatted", "read", vec![root_for(&p)])
829 .unwrap();
830
831 std::fs::remove_file(&p).unwrap();
832
833 match cache.lookup_revalidate(&key).unwrap() {
834 LookupOutcome::Invalidated => {}
835 other => panic!("expected Invalidated, got {other:?}"),
836 }
837 }
838
839 #[test]
840 fn mark_dirty_drops_entry() {
841 let dir = TempDir::new().unwrap();
842 let cache = cache(&dir);
843 let p = write_file(&dir, "e.txt", b"echo");
844 let key = Key::from_bytes(b"read|e.txt|echo");
845 cache
846 .persist(&key, b"echo-formatted", "read", vec![root_for(&p)])
847 .unwrap();
848 assert_eq!(cache.entry_count(), 1);
849 cache.mark_dirty(&key);
850 assert_eq!(cache.entry_count(), 0);
851 assert_eq!(cache.lookup(&key).unwrap(), LookupOutcome::Miss);
852 }
853
854 #[test]
855 fn invalidate_path_drops_matching_entries() {
856 let dir = TempDir::new().unwrap();
857 let cache = cache(&dir);
858 let p1 = write_file(&dir, "f1.txt", b"foxtrot");
859 let p2 = write_file(&dir, "f2.txt", b"foxtrot2");
860 let k1 = Key::from_bytes(b"read|f1");
861 let k2 = Key::from_bytes(b"read|f2");
862 cache
863 .persist(&k1, b"f1-out", "read", vec![root_for(&p1)])
864 .unwrap();
865 cache
866 .persist(&k2, b"f2-out", "read", vec![root_for(&p2)])
867 .unwrap();
868 assert_eq!(cache.entry_count(), 2);
869 let n = cache.invalidate_path(&p1);
870 assert_eq!(n, 1);
871 assert_eq!(cache.entry_count(), 1);
872 match cache.lookup(&k2).unwrap() {
874 LookupOutcome::Hit(_) => {}
875 other => panic!("k2 should still hit, got {other:?}"),
876 }
877 match cache.lookup(&k1).unwrap() {
878 LookupOutcome::Miss => {}
879 other => panic!("k1 should miss, got {other:?}"),
880 }
881 }
882
883 #[test]
884 fn invalidate_path_matches_case_insensitively() {
885 let dir = TempDir::new().unwrap();
890 let cache = cache(&dir);
891 let p = write_file(&dir, "CaseFile.txt", b"contents");
892 let key = Key::from_bytes(b"read|casefile");
893 cache
894 .persist(&key, b"formatted", "read", vec![root_for(&p)])
895 .unwrap();
896 assert_eq!(cache.entry_count(), 1);
897
898 let differently_cased = dir.path().join("casefile.txt");
899 let n = cache.invalidate_path(&differently_cased);
900 assert_eq!(n, 1, "case-differing path must still invalidate the entry");
901 assert_eq!(cache.entry_count(), 0);
902 }
903
904 #[test]
905 fn multi_root_revalidation() {
906 let dir = TempDir::new().unwrap();
907 let cache = cache(&dir);
908 let p1 = write_file(&dir, "g1.txt", b"golf1");
909 let p2 = write_file(&dir, "g2.txt", b"golf2");
910 let key = Key::from_bytes(b"grep|pattern|g1+g2");
911 cache
912 .persist(
913 &key,
914 b"merged-output",
915 "grep",
916 vec![root_for(&p1), root_for(&p2)],
917 )
918 .unwrap();
919
920 match cache.lookup_revalidate(&key).unwrap() {
922 LookupOutcome::Hit(_) => {}
923 other => panic!("expected Hit, got {other:?}"),
924 }
925 std::fs::write(&p2, b"changed").unwrap();
927 match cache.lookup_revalidate(&key).unwrap() {
928 LookupOutcome::Invalidated => {}
929 other => panic!("expected Invalidated, got {other:?}"),
930 }
931 }
932
933 #[test]
934 fn upstream_invalidation_drops_dependents() {
935 let dir = TempDir::new().unwrap();
936 let cache = cache(&dir);
937 let p = write_file(&dir, "src.txt", b"alpha");
938 let read_key = Key::from_bytes(b"read|src");
939 cache
940 .persist(&read_key, b"alpha-formatted", "read", vec![root_for(&p)])
941 .unwrap();
942 let llm1 = Key::from_bytes(b"llm|first-prompt");
944 let llm2 = Key::from_bytes(b"llm|second-prompt");
945 cache
946 .persist_with_upstreams(
947 &llm1,
948 b"completion-1",
949 "llm_call",
950 vec![],
951 vec![read_key.clone()],
952 )
953 .unwrap();
954 cache
955 .persist_with_upstreams(
956 &llm2,
957 b"completion-2",
958 "llm_call",
959 vec![],
960 vec![read_key.clone()],
961 )
962 .unwrap();
963 assert_eq!(cache.entry_count(), 3);
964
965 let dropped = cache.invalidate_upstream(&read_key);
967 assert_eq!(dropped, 2);
968 assert_eq!(cache.lookup(&llm1).unwrap(), LookupOutcome::Miss);
969 assert_eq!(cache.lookup(&llm2).unwrap(), LookupOutcome::Miss);
970 }
971
972 #[test]
973 fn invalidate_path_cascades_to_dependent_llm_calls() {
974 let dir = TempDir::new().unwrap();
975 let cache = cache(&dir);
976 let p = write_file(&dir, "input.txt", b"hello");
977 let read_key = Key::from_bytes(b"read|input");
978 cache
979 .persist(&read_key, b"hello-formatted", "read", vec![root_for(&p)])
980 .unwrap();
981 let llm = Key::from_bytes(b"llm|sees-read");
982 cache
983 .persist_with_upstreams(
984 &llm,
985 b"completion",
986 "llm_call",
987 vec![],
988 vec![read_key.clone()],
989 )
990 .unwrap();
991 assert_eq!(cache.entry_count(), 2);
992
993 std::fs::write(&p, b"changed").unwrap();
995 let n = cache.invalidate_path(&p);
996 assert_eq!(n, 1, "the read entry was the direct path match");
997 assert_eq!(cache.lookup(&llm).unwrap(), LookupOutcome::Miss);
999 assert_eq!(cache.entry_count(), 0);
1000 }
1001
1002 #[test]
1003 fn transitive_invalidation_walks_multi_hop_chain() {
1004 let dir = TempDir::new().unwrap();
1006 let cache = cache(&dir);
1007 let key_a = Key::from_bytes(b"a");
1008 let key_b = Key::from_bytes(b"b");
1009 let key_c = Key::from_bytes(b"c");
1010 let p = write_file(&dir, "f.txt", b"x");
1011 cache
1012 .persist(&key_a, b"a-bytes", "read", vec![root_for(&p)])
1013 .unwrap();
1014 cache
1015 .persist_with_upstreams(&key_b, b"b-bytes", "llm_call", vec![], vec![key_a.clone()])
1016 .unwrap();
1017 cache
1018 .persist_with_upstreams(&key_c, b"c-bytes", "llm_call", vec![], vec![key_b.clone()])
1019 .unwrap();
1020
1021 let dropped = cache.invalidate_upstream(&key_a);
1022 assert_eq!(dropped, 2);
1023 assert_eq!(cache.lookup(&key_b).unwrap(), LookupOutcome::Miss);
1024 assert_eq!(cache.lookup(&key_c).unwrap(), LookupOutcome::Miss);
1025 }
1026
1027 #[test]
1028 fn upstream_keys_persist_across_rehydration() {
1029 let dir = TempDir::new().unwrap();
1030 let p = write_file(&dir, "g.txt", b"data");
1031 let read_key = Key::from_bytes(b"read|g");
1032 let llm_key = Key::from_bytes(b"llm|g-consumer");
1033
1034 {
1035 let cache = cache(&dir);
1036 cache
1037 .persist(&read_key, b"data-formatted", "read", vec![root_for(&p)])
1038 .unwrap();
1039 cache
1040 .persist_with_upstreams(
1041 &llm_key,
1042 b"completion",
1043 "llm_call",
1044 vec![],
1045 vec![read_key.clone()],
1046 )
1047 .unwrap();
1048 }
1049
1050 let store_root = dir.path().join("store");
1053 let store2 = crate::store::FileStore::open(store_root).unwrap();
1054 let cache2 = LiveCache::new(store2);
1055 assert_eq!(cache2.entry_count(), 2);
1056 let dropped = cache2.invalidate_upstream(&read_key);
1057 assert_eq!(dropped, 1, "rehydrated edge must support cascade");
1058 }
1059
1060 #[test]
1061 fn cross_instance_file_edit_cascades_tool_and_llm() {
1062 let dir = TempDir::new().unwrap();
1068 let f = write_file(&dir, "dep.txt", b"v1");
1069 let content = b"TOOL: contents of dep.txt";
1070 let tkey = tool_result_key(content);
1071 let llm_key = Key::from_bytes(b"llm|consumed-the-tool-result");
1072
1073 {
1074 let producer = cache(&dir);
1075 producer
1076 .persist(&tkey, content, "tool_result", vec![root_for(&f)])
1077 .unwrap();
1078 producer
1079 .persist_with_upstreams(
1080 &llm_key,
1081 b"completion-bytes",
1082 "llm_call",
1083 vec![],
1084 vec![tkey.clone()],
1085 )
1086 .unwrap();
1087 }
1088
1089 {
1090 let editor = cache(&dir);
1091 std::fs::write(&f, b"v2-changed").unwrap();
1092 let n = editor.invalidate_path(&f);
1093 assert!(
1094 n >= 1,
1095 "the tool node depending on the file must be dropped"
1096 );
1097 }
1098
1099 let reader = cache(&dir);
1100 assert!(
1101 matches!(reader.lookup(&tkey).unwrap(), LookupOutcome::Miss),
1102 "tool result node must be gone cross-instance"
1103 );
1104 assert!(
1105 matches!(reader.lookup(&llm_key).unwrap(), LookupOutcome::Miss),
1106 "the dependent LLM completion must be gone cross-instance"
1107 );
1108 }
1109
1110 #[test]
1111 fn fresh_cache_rehydrates_from_store_on_disk() {
1112 let dir = TempDir::new().unwrap();
1117 let p = write_file(&dir, "rehydrate.txt", b"persist me");
1118 let key = Key::from_bytes(b"read|rehydrate|persist me");
1119
1120 {
1121 let cache = cache(&dir);
1122 cache
1123 .persist(&key, b"served-once", "read", vec![root_for(&p)])
1124 .unwrap();
1125 assert_eq!(cache.entry_count(), 1);
1126 } let store_root = dir.path().join("store");
1129 let store2 = crate::store::FileStore::open(store_root).unwrap();
1130 let cache2 = LiveCache::new(store2);
1131 assert_eq!(cache2.entry_count(), 1);
1134 match cache2.lookup_revalidate(&key).unwrap() {
1135 LookupOutcome::Hit(payload) => assert_eq!(payload.bytes, b"served-once"),
1136 other => panic!("expected Hit after rehydrate, got {other:?}"),
1137 }
1138 }
1139
1140 #[test]
1141 fn hit_returns_byte_identical_payload() {
1142 let dir = TempDir::new().unwrap();
1146 let cache = cache(&dir);
1147 let p = write_file(&dir, "h.txt", b"hotel");
1148 let key = Key::from_bytes(b"read|h");
1149 let original = b" 1\thotel-formatted-with-line-numbers\n 2\tetc\n";
1150 cache
1151 .persist(&key, original, "read", vec![root_for(&p)])
1152 .unwrap();
1153 match cache.lookup_revalidate(&key).unwrap() {
1154 LookupOutcome::Hit(p) => assert_eq!(p.bytes, original),
1155 other => panic!("expected Hit, got {other:?}"),
1156 }
1157 }
1158
1159 #[test]
1160 fn hash_file_with_limit_content_hashes_within_limit() {
1161 let dir = TempDir::new().unwrap();
1162 let p = write_file(&dir, "small.bin", b"comfortably within the limit");
1163 match hash_file_with_limit(&p, 1024).unwrap() {
1164 FileHash::Content(h) => assert_eq!(h, hash_file(&p).unwrap()),
1165 FileHash::Oversized => panic!("a file within the limit must content-hash"),
1166 }
1167 }
1168
1169 #[test]
1170 fn hash_file_with_limit_reports_oversized_above_limit() {
1171 let dir = TempDir::new().unwrap();
1172 let p = write_file(&dir, "big.bin", &[7u8; 4096]);
1173 assert_eq!(hash_file_with_limit(&p, 64).unwrap(), FileHash::Oversized);
1174 }
1175
1176 #[test]
1177 fn oversized_files_yield_no_keyable_digest() {
1178 let dir = TempDir::new().unwrap();
1183 let a = write_file(&dir, "a.bin", &[1u8; 4096]);
1184 let b = write_file(&dir, "b.bin", &[2u8; 4096]);
1185 let ha = hash_file_with_limit(&a, 64).unwrap();
1186 let hb = hash_file_with_limit(&b, 64).unwrap();
1187 assert_eq!(ha, FileHash::Oversized);
1188 assert_eq!(hb, FileHash::Oversized);
1189 assert!(ha.content().is_none() && hb.content().is_none());
1190 }
1191}