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(STAT_PREFIX) {
500 match stat_fingerprint(&resolved) {
501 Ok(s) => s,
502 Err(_) => {
503 if debug {
504 eprintln!(
505 "verdant: invalidated by missing/unreadable {}",
506 resolved.display()
507 );
508 }
509 return RevalidationOutcome::Invalidated;
510 }
511 }
512 } else {
513 match hash_file(&resolved) {
514 Ok(h) => h,
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 };
526 if current != root.expected_hash {
527 if debug {
528 eprintln!("verdant: invalidated by changed {}", resolved.display());
529 }
530 return RevalidationOutcome::Invalidated;
531 }
532 }
533 RevalidationOutcome::Ok
534}
535
536fn resolve_root_path(workspace_base: &Path, recorded: &Path) -> PathBuf {
544 workspace_base.join(recorded)
545}
546
547fn lower_path(p: &Path) -> String {
550 p.to_string_lossy().to_lowercase()
551}
552
553const HASH_MAX_BYTES: u64 = 100 * 1024 * 1024;
558
559pub fn hash_max_bytes() -> u64 {
562 std::env::var("VERDANT_HASH_MAX_BYTES")
563 .ok()
564 .and_then(|s| s.parse::<u64>().ok())
565 .unwrap_or(HASH_MAX_BYTES)
566}
567
568#[derive(Debug, Clone, PartialEq, Eq)]
570pub enum FileHash {
571 Content(String),
573 Oversized,
578}
579
580impl FileHash {
581 pub fn content(&self) -> Option<&str> {
582 match self {
583 FileHash::Content(h) => Some(h),
584 FileHash::Oversized => None,
585 }
586 }
587}
588
589pub fn hash_file(path: &Path) -> std::io::Result<String> {
593 let mut hasher = blake3::Hasher::new();
594 let mut f = std::fs::File::open(path)?;
595 let mut buf = [0u8; 1 << 16];
596 loop {
597 let n = std::io::Read::read(&mut f, &mut buf)?;
598 if n == 0 {
599 break;
600 }
601 hasher.update(&buf[..n]);
602 }
603 Ok(hasher.finalize().to_hex().to_string())
604}
605
606pub fn hash_file_with_limit(path: &Path, max: u64) -> std::io::Result<FileHash> {
610 if std::fs::metadata(path)?.len() > max {
611 return Ok(FileHash::Oversized);
612 }
613 Ok(FileHash::Content(hash_file(path)?))
614}
615
616pub fn tool_result_key(content: &[u8]) -> Key {
623 let mut framed = Vec::with_capacity(content.len() + 12);
624 framed.extend_from_slice(b"tool_result\0");
625 framed.extend_from_slice(content);
626 Key::from_bytes(&framed)
627}
628
629pub const STAT_PREFIX: &str = "stat:";
630
631pub fn stat_fingerprint(path: &Path) -> std::io::Result<String> {
637 let m = std::fs::metadata(path)?;
638 Ok(format!(
639 "{STAT_PREFIX}{}:{}:{}",
640 m.len(),
641 m.mtime(),
642 m.mtime_nsec()
643 ))
644}
645
646pub fn fingerprint_file(path: &Path, content_max: u64) -> std::io::Result<String> {
653 if std::fs::metadata(path)?.len() > content_max {
654 stat_fingerprint(path)
655 } else {
656 hash_file(path)
657 }
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663 use tempfile::TempDir;
664
665 fn cache(dir: &TempDir) -> LiveCache {
666 let store = crate::store::FileStore::open(dir.path().join("store")).unwrap();
667 LiveCache::new(store)
668 }
669
670 fn write_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf {
671 let p = dir.path().join(name);
672 std::fs::write(&p, content).unwrap();
673 p
674 }
675
676 fn root_for(p: &Path) -> FileRoot {
677 FileRoot {
678 path: p.to_path_buf(),
679 expected_hash: hash_file(p).unwrap(),
680 }
681 }
682
683 #[test]
684 fn miss_then_persist_then_hit() {
685 let dir = TempDir::new().unwrap();
686 let cache = cache(&dir);
687 let p = write_file(&dir, "a.txt", b"alpha");
688 let key = Key::from_bytes(b"read|a.txt|alpha");
689
690 assert_eq!(cache.lookup(&key).unwrap(), LookupOutcome::Miss);
691
692 cache
693 .persist(&key, b"alpha-formatted", "read", vec![root_for(&p)])
694 .unwrap();
695
696 match cache.lookup(&key).unwrap() {
697 LookupOutcome::Hit(payload) => {
698 assert_eq!(payload.bytes, b"alpha-formatted");
699 assert_eq!(payload.meta.tool_kind, "read");
700 }
701 other => panic!("expected Hit, got {other:?}"),
702 }
703 }
704
705 #[test]
706 fn revalidate_unchanged_returns_hit() {
707 let dir = TempDir::new().unwrap();
708 let cache = cache(&dir);
709 let p = write_file(&dir, "b.txt", b"beta");
710 let key = Key::from_bytes(b"read|b.txt|beta");
711 cache
712 .persist(&key, b"beta-formatted", "read", vec![root_for(&p)])
713 .unwrap();
714 match cache.lookup_revalidate(&key).unwrap() {
715 LookupOutcome::Hit(_) => {}
716 other => panic!("expected Hit, got {other:?}"),
717 }
718 }
719
720 #[test]
721 fn revalidate_modified_invalidates() {
722 let dir = TempDir::new().unwrap();
723 let cache = cache(&dir);
724 let p = write_file(&dir, "c.txt", b"charlie");
725 let key = Key::from_bytes(b"read|c.txt|charlie");
726 cache
727 .persist(&key, b"charlie-formatted", "read", vec![root_for(&p)])
728 .unwrap();
729
730 std::fs::write(&p, b"DELTA").unwrap();
731
732 match cache.lookup_revalidate(&key).unwrap() {
733 LookupOutcome::Invalidated => {}
734 other => panic!("expected Invalidated, got {other:?}"),
735 }
736 assert_eq!(cache.entry_count(), 0);
737 }
738
739 #[test]
740 fn revalidate_deleted_invalidates() {
741 let dir = TempDir::new().unwrap();
742 let cache = cache(&dir);
743 let p = write_file(&dir, "d.txt", b"delta");
744 let key = Key::from_bytes(b"read|d.txt|delta");
745 cache
746 .persist(&key, b"delta-formatted", "read", vec![root_for(&p)])
747 .unwrap();
748
749 std::fs::remove_file(&p).unwrap();
750
751 match cache.lookup_revalidate(&key).unwrap() {
752 LookupOutcome::Invalidated => {}
753 other => panic!("expected Invalidated, got {other:?}"),
754 }
755 }
756
757 #[test]
758 fn mark_dirty_drops_entry() {
759 let dir = TempDir::new().unwrap();
760 let cache = cache(&dir);
761 let p = write_file(&dir, "e.txt", b"echo");
762 let key = Key::from_bytes(b"read|e.txt|echo");
763 cache
764 .persist(&key, b"echo-formatted", "read", vec![root_for(&p)])
765 .unwrap();
766 assert_eq!(cache.entry_count(), 1);
767 cache.mark_dirty(&key);
768 assert_eq!(cache.entry_count(), 0);
769 assert_eq!(cache.lookup(&key).unwrap(), LookupOutcome::Miss);
770 }
771
772 #[test]
773 fn invalidate_path_drops_matching_entries() {
774 let dir = TempDir::new().unwrap();
775 let cache = cache(&dir);
776 let p1 = write_file(&dir, "f1.txt", b"foxtrot");
777 let p2 = write_file(&dir, "f2.txt", b"foxtrot2");
778 let k1 = Key::from_bytes(b"read|f1");
779 let k2 = Key::from_bytes(b"read|f2");
780 cache
781 .persist(&k1, b"f1-out", "read", vec![root_for(&p1)])
782 .unwrap();
783 cache
784 .persist(&k2, b"f2-out", "read", vec![root_for(&p2)])
785 .unwrap();
786 assert_eq!(cache.entry_count(), 2);
787 let n = cache.invalidate_path(&p1);
788 assert_eq!(n, 1);
789 assert_eq!(cache.entry_count(), 1);
790 match cache.lookup(&k2).unwrap() {
792 LookupOutcome::Hit(_) => {}
793 other => panic!("k2 should still hit, got {other:?}"),
794 }
795 match cache.lookup(&k1).unwrap() {
796 LookupOutcome::Miss => {}
797 other => panic!("k1 should miss, got {other:?}"),
798 }
799 }
800
801 #[test]
802 fn invalidate_path_matches_case_insensitively() {
803 let dir = TempDir::new().unwrap();
808 let cache = cache(&dir);
809 let p = write_file(&dir, "CaseFile.txt", b"contents");
810 let key = Key::from_bytes(b"read|casefile");
811 cache
812 .persist(&key, b"formatted", "read", vec![root_for(&p)])
813 .unwrap();
814 assert_eq!(cache.entry_count(), 1);
815
816 let differently_cased = dir.path().join("casefile.txt");
817 let n = cache.invalidate_path(&differently_cased);
818 assert_eq!(n, 1, "case-differing path must still invalidate the entry");
819 assert_eq!(cache.entry_count(), 0);
820 }
821
822 #[test]
823 fn multi_root_revalidation() {
824 let dir = TempDir::new().unwrap();
825 let cache = cache(&dir);
826 let p1 = write_file(&dir, "g1.txt", b"golf1");
827 let p2 = write_file(&dir, "g2.txt", b"golf2");
828 let key = Key::from_bytes(b"grep|pattern|g1+g2");
829 cache
830 .persist(
831 &key,
832 b"merged-output",
833 "grep",
834 vec![root_for(&p1), root_for(&p2)],
835 )
836 .unwrap();
837
838 match cache.lookup_revalidate(&key).unwrap() {
840 LookupOutcome::Hit(_) => {}
841 other => panic!("expected Hit, got {other:?}"),
842 }
843 std::fs::write(&p2, b"changed").unwrap();
845 match cache.lookup_revalidate(&key).unwrap() {
846 LookupOutcome::Invalidated => {}
847 other => panic!("expected Invalidated, got {other:?}"),
848 }
849 }
850
851 #[test]
852 fn upstream_invalidation_drops_dependents() {
853 let dir = TempDir::new().unwrap();
854 let cache = cache(&dir);
855 let p = write_file(&dir, "src.txt", b"alpha");
856 let read_key = Key::from_bytes(b"read|src");
857 cache
858 .persist(&read_key, b"alpha-formatted", "read", vec![root_for(&p)])
859 .unwrap();
860 let llm1 = Key::from_bytes(b"llm|first-prompt");
862 let llm2 = Key::from_bytes(b"llm|second-prompt");
863 cache
864 .persist_with_upstreams(
865 &llm1,
866 b"completion-1",
867 "llm_call",
868 vec![],
869 vec![read_key.clone()],
870 )
871 .unwrap();
872 cache
873 .persist_with_upstreams(
874 &llm2,
875 b"completion-2",
876 "llm_call",
877 vec![],
878 vec![read_key.clone()],
879 )
880 .unwrap();
881 assert_eq!(cache.entry_count(), 3);
882
883 let dropped = cache.invalidate_upstream(&read_key);
885 assert_eq!(dropped, 2);
886 assert_eq!(cache.lookup(&llm1).unwrap(), LookupOutcome::Miss);
887 assert_eq!(cache.lookup(&llm2).unwrap(), LookupOutcome::Miss);
888 }
889
890 #[test]
891 fn invalidate_path_cascades_to_dependent_llm_calls() {
892 let dir = TempDir::new().unwrap();
893 let cache = cache(&dir);
894 let p = write_file(&dir, "input.txt", b"hello");
895 let read_key = Key::from_bytes(b"read|input");
896 cache
897 .persist(&read_key, b"hello-formatted", "read", vec![root_for(&p)])
898 .unwrap();
899 let llm = Key::from_bytes(b"llm|sees-read");
900 cache
901 .persist_with_upstreams(
902 &llm,
903 b"completion",
904 "llm_call",
905 vec![],
906 vec![read_key.clone()],
907 )
908 .unwrap();
909 assert_eq!(cache.entry_count(), 2);
910
911 std::fs::write(&p, b"changed").unwrap();
913 let n = cache.invalidate_path(&p);
914 assert_eq!(n, 1, "the read entry was the direct path match");
915 assert_eq!(cache.lookup(&llm).unwrap(), LookupOutcome::Miss);
917 assert_eq!(cache.entry_count(), 0);
918 }
919
920 #[test]
921 fn transitive_invalidation_walks_multi_hop_chain() {
922 let dir = TempDir::new().unwrap();
924 let cache = cache(&dir);
925 let key_a = Key::from_bytes(b"a");
926 let key_b = Key::from_bytes(b"b");
927 let key_c = Key::from_bytes(b"c");
928 let p = write_file(&dir, "f.txt", b"x");
929 cache
930 .persist(&key_a, b"a-bytes", "read", vec![root_for(&p)])
931 .unwrap();
932 cache
933 .persist_with_upstreams(&key_b, b"b-bytes", "llm_call", vec![], vec![key_a.clone()])
934 .unwrap();
935 cache
936 .persist_with_upstreams(&key_c, b"c-bytes", "llm_call", vec![], vec![key_b.clone()])
937 .unwrap();
938
939 let dropped = cache.invalidate_upstream(&key_a);
940 assert_eq!(dropped, 2);
941 assert_eq!(cache.lookup(&key_b).unwrap(), LookupOutcome::Miss);
942 assert_eq!(cache.lookup(&key_c).unwrap(), LookupOutcome::Miss);
943 }
944
945 #[test]
946 fn upstream_keys_persist_across_rehydration() {
947 let dir = TempDir::new().unwrap();
948 let p = write_file(&dir, "g.txt", b"data");
949 let read_key = Key::from_bytes(b"read|g");
950 let llm_key = Key::from_bytes(b"llm|g-consumer");
951
952 {
953 let cache = cache(&dir);
954 cache
955 .persist(&read_key, b"data-formatted", "read", vec![root_for(&p)])
956 .unwrap();
957 cache
958 .persist_with_upstreams(
959 &llm_key,
960 b"completion",
961 "llm_call",
962 vec![],
963 vec![read_key.clone()],
964 )
965 .unwrap();
966 }
967
968 let store_root = dir.path().join("store");
971 let store2 = crate::store::FileStore::open(store_root).unwrap();
972 let cache2 = LiveCache::new(store2);
973 assert_eq!(cache2.entry_count(), 2);
974 let dropped = cache2.invalidate_upstream(&read_key);
975 assert_eq!(dropped, 1, "rehydrated edge must support cascade");
976 }
977
978 #[test]
979 fn cross_instance_file_edit_cascades_tool_and_llm() {
980 let dir = TempDir::new().unwrap();
986 let f = write_file(&dir, "dep.txt", b"v1");
987 let content = b"TOOL: contents of dep.txt";
988 let tkey = tool_result_key(content);
989 let llm_key = Key::from_bytes(b"llm|consumed-the-tool-result");
990
991 {
992 let producer = cache(&dir);
993 producer
994 .persist(&tkey, content, "tool_result", vec![root_for(&f)])
995 .unwrap();
996 producer
997 .persist_with_upstreams(
998 &llm_key,
999 b"completion-bytes",
1000 "llm_call",
1001 vec![],
1002 vec![tkey.clone()],
1003 )
1004 .unwrap();
1005 }
1006
1007 {
1008 let editor = cache(&dir);
1009 std::fs::write(&f, b"v2-changed").unwrap();
1010 let n = editor.invalidate_path(&f);
1011 assert!(
1012 n >= 1,
1013 "the tool node depending on the file must be dropped"
1014 );
1015 }
1016
1017 let reader = cache(&dir);
1018 assert!(
1019 matches!(reader.lookup(&tkey).unwrap(), LookupOutcome::Miss),
1020 "tool result node must be gone cross-instance"
1021 );
1022 assert!(
1023 matches!(reader.lookup(&llm_key).unwrap(), LookupOutcome::Miss),
1024 "the dependent LLM completion must be gone cross-instance"
1025 );
1026 }
1027
1028 #[test]
1029 fn fresh_cache_rehydrates_from_store_on_disk() {
1030 let dir = TempDir::new().unwrap();
1035 let p = write_file(&dir, "rehydrate.txt", b"persist me");
1036 let key = Key::from_bytes(b"read|rehydrate|persist me");
1037
1038 {
1039 let cache = cache(&dir);
1040 cache
1041 .persist(&key, b"served-once", "read", vec![root_for(&p)])
1042 .unwrap();
1043 assert_eq!(cache.entry_count(), 1);
1044 } let store_root = dir.path().join("store");
1047 let store2 = crate::store::FileStore::open(store_root).unwrap();
1048 let cache2 = LiveCache::new(store2);
1049 assert_eq!(cache2.entry_count(), 1);
1052 match cache2.lookup_revalidate(&key).unwrap() {
1053 LookupOutcome::Hit(payload) => assert_eq!(payload.bytes, b"served-once"),
1054 other => panic!("expected Hit after rehydrate, got {other:?}"),
1055 }
1056 }
1057
1058 #[test]
1059 fn hit_returns_byte_identical_payload() {
1060 let dir = TempDir::new().unwrap();
1064 let cache = cache(&dir);
1065 let p = write_file(&dir, "h.txt", b"hotel");
1066 let key = Key::from_bytes(b"read|h");
1067 let original = b" 1\thotel-formatted-with-line-numbers\n 2\tetc\n";
1068 cache
1069 .persist(&key, original, "read", vec![root_for(&p)])
1070 .unwrap();
1071 match cache.lookup_revalidate(&key).unwrap() {
1072 LookupOutcome::Hit(p) => assert_eq!(p.bytes, original),
1073 other => panic!("expected Hit, got {other:?}"),
1074 }
1075 }
1076
1077 #[test]
1078 fn hash_file_with_limit_content_hashes_within_limit() {
1079 let dir = TempDir::new().unwrap();
1080 let p = write_file(&dir, "small.bin", b"comfortably within the limit");
1081 match hash_file_with_limit(&p, 1024).unwrap() {
1082 FileHash::Content(h) => assert_eq!(h, hash_file(&p).unwrap()),
1083 FileHash::Oversized => panic!("a file within the limit must content-hash"),
1084 }
1085 }
1086
1087 #[test]
1088 fn hash_file_with_limit_reports_oversized_above_limit() {
1089 let dir = TempDir::new().unwrap();
1090 let p = write_file(&dir, "big.bin", &[7u8; 4096]);
1091 assert_eq!(hash_file_with_limit(&p, 64).unwrap(), FileHash::Oversized);
1092 }
1093
1094 #[test]
1095 fn oversized_files_yield_no_keyable_digest() {
1096 let dir = TempDir::new().unwrap();
1101 let a = write_file(&dir, "a.bin", &[1u8; 4096]);
1102 let b = write_file(&dir, "b.bin", &[2u8; 4096]);
1103 let ha = hash_file_with_limit(&a, 64).unwrap();
1104 let hb = hash_file_with_limit(&b, 64).unwrap();
1105 assert_eq!(ha, FileHash::Oversized);
1106 assert_eq!(hb, FileHash::Oversized);
1107 assert!(ha.content().is_none() && hb.content().is_none());
1108 }
1109}