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 let Some(rest) = expected.strip_prefix(PROBE_PREFIX) {
497 let (class, follow) = parse_probe_header(rest).ok_or_else(|| {
498 std::io::Error::new(
499 std::io::ErrorKind::InvalidData,
500 format!("unparseable probe fingerprint {expected:?}"),
501 )
502 })?;
503 Ok(probe_fingerprint(path, follow, class))
504 } else if expected.starts_with(DIR_PREFIX) {
505 fingerprint_dir(path)
506 } else if expected.starts_with(STAT_PREFIX) {
507 stat_fingerprint(path)
508 } else {
509 hash_file(path)
510 }
511}
512
513fn revalidate_file_roots(workspace_base: &Path, roots: &[FileRoot]) -> RevalidationOutcome {
514 let debug = std::env::var_os("VERDANT_DEBUG_INVALIDATION").is_some();
515 for root in roots {
516 let resolved = resolve_root_path(workspace_base, &root.path);
517 let current = match current_root_fingerprint(&resolved, &root.expected_hash) {
518 Ok(c) => c,
519 Err(_) => {
520 if debug {
521 eprintln!(
522 "verdant: invalidated by missing/unreadable {}",
523 resolved.display()
524 );
525 }
526 return RevalidationOutcome::Invalidated;
527 }
528 };
529 if current != root.expected_hash {
530 if debug {
531 eprintln!("verdant: invalidated by changed {}", resolved.display());
532 }
533 return RevalidationOutcome::Invalidated;
534 }
535 }
536 RevalidationOutcome::Ok
537}
538
539fn resolve_root_path(workspace_base: &Path, recorded: &Path) -> PathBuf {
547 workspace_base.join(recorded)
548}
549
550fn lower_path(p: &Path) -> String {
553 p.to_string_lossy().to_lowercase()
554}
555
556const HASH_MAX_BYTES: u64 = 100 * 1024 * 1024;
561
562pub fn hash_max_bytes() -> u64 {
565 std::env::var("VERDANT_HASH_MAX_BYTES")
566 .ok()
567 .and_then(|s| s.parse::<u64>().ok())
568 .unwrap_or(HASH_MAX_BYTES)
569}
570
571#[derive(Debug, Clone, PartialEq, Eq)]
573pub enum FileHash {
574 Content(String),
576 Oversized,
581}
582
583impl FileHash {
584 pub fn content(&self) -> Option<&str> {
585 match self {
586 FileHash::Content(h) => Some(h),
587 FileHash::Oversized => None,
588 }
589 }
590}
591
592pub fn hash_file(path: &Path) -> std::io::Result<String> {
596 let mut hasher = blake3::Hasher::new();
597 let mut f = std::fs::File::open(path)?;
598 let mut buf = [0u8; 1 << 16];
599 loop {
600 let n = std::io::Read::read(&mut f, &mut buf)?;
601 if n == 0 {
602 break;
603 }
604 hasher.update(&buf[..n]);
605 }
606 Ok(hasher.finalize().to_hex().to_string())
607}
608
609pub fn hash_file_with_limit(path: &Path, max: u64) -> std::io::Result<FileHash> {
613 if std::fs::metadata(path)?.len() > max {
614 return Ok(FileHash::Oversized);
615 }
616 Ok(FileHash::Content(hash_file(path)?))
617}
618
619pub fn tool_result_key(content: &[u8]) -> Key {
626 let mut framed = Vec::with_capacity(content.len() + 12);
627 framed.extend_from_slice(b"tool_result\0");
628 framed.extend_from_slice(content);
629 Key::from_bytes(&framed)
630}
631
632pub const STAT_PREFIX: &str = "stat:";
633pub const DIR_PREFIX: &str = "dir:";
634
635pub fn fingerprint_dir(path: &Path) -> std::io::Result<String> {
643 let mut entries: Vec<(Vec<u8>, u8)> = Vec::new();
644 for entry in std::fs::read_dir(path)? {
645 let entry = entry?;
646 let ty = entry.file_type()?;
647 let tag = if ty.is_file() {
648 b'f'
649 } else if ty.is_dir() {
650 b'd'
651 } else if ty.is_symlink() {
652 b'l'
653 } else {
654 b'o'
655 };
656 entries.push((entry.file_name().as_encoded_bytes().to_vec(), tag));
657 }
658 entries.sort();
659 let mut hasher = blake3::Hasher::new();
660 for (name, tag) in &entries {
661 hasher.update(&(name.len() as u64).to_le_bytes());
662 hasher.update(name);
663 hasher.update(&[*tag]);
664 }
665 Ok(format!("{DIR_PREFIX}{}", hasher.finalize().to_hex()))
666}
667
668pub const PROBE_PREFIX: &str = "probe:";
669
670#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
671pub enum ProbeClass {
672 Meta,
673 PreChangeMeta,
674 Kind,
675 Link,
676 Exists,
679}
680
681impl ProbeClass {
682 fn token(self) -> &'static str {
683 match self {
684 ProbeClass::Meta => "meta",
685 ProbeClass::PreChangeMeta => "pre",
686 ProbeClass::Kind => "kind",
687 ProbeClass::Link => "link",
688 ProbeClass::Exists => "exists",
689 }
690 }
691
692 fn from_token(token: &str) -> Option<Self> {
693 match token {
694 "meta" => Some(ProbeClass::Meta),
695 "pre" => Some(ProbeClass::PreChangeMeta),
696 "kind" => Some(ProbeClass::Kind),
697 "link" => Some(ProbeClass::Link),
698 "exists" => Some(ProbeClass::Exists),
699 _ => None,
700 }
701 }
702}
703
704#[derive(Debug, Clone)]
706pub enum ProbeObserved {
707 Stat(Result<std::fs::Metadata, i32>),
708 Link(Result<PathBuf, i32>),
709}
710
711fn errno(e: &std::io::Error) -> i32 {
712 e.raw_os_error().unwrap_or(-1)
713}
714
715pub fn observe_probe(path: &Path, follow: bool, class: ProbeClass) -> ProbeObserved {
717 match class {
718 ProbeClass::Link => ProbeObserved::Link(std::fs::read_link(path).map_err(|e| errno(&e))),
719 _ => {
720 let md = if follow {
721 std::fs::metadata(path)
722 } else {
723 std::fs::symlink_metadata(path)
724 };
725 ProbeObserved::Stat(md.map_err(|e| errno(&e)))
726 }
727 }
728}
729
730pub fn probe_fingerprint_from(observed: &ProbeObserved, class: ProbeClass, follow: bool) -> String {
733 let flag = if follow { 'f' } else { 'n' };
734 let payload = match (class, observed) {
735 (ProbeClass::Link, ProbeObserved::Link(Ok(target))) => {
736 blake3::hash(target.as_os_str().as_encoded_bytes())
737 .to_hex()
738 .to_string()
739 }
740 (ProbeClass::Link, ProbeObserved::Link(Err(e))) => format!("err{e}"),
741 (ProbeClass::Link, ProbeObserved::Stat(_)) | (_, ProbeObserved::Link(_)) => {
742 "invalid".to_string()
743 }
744 (_, ProbeObserved::Stat(Err(e))) => format!("err{e}"),
745 (ProbeClass::Exists, ProbeObserved::Stat(Ok(m))) => format!("t{:o}", m.mode() & 0o170000),
746 (_, ProbeObserved::Stat(Ok(m))) => {
747 let mut out = format!(
748 "t{:o},m{:o},u{},g{},d{},i{},n{}",
749 m.mode() & 0o170000,
750 m.mode() & 0o7777,
751 m.uid(),
752 m.gid(),
753 m.dev(),
754 m.ino(),
755 m.nlink()
756 );
757 if matches!(class, ProbeClass::Meta | ProbeClass::PreChangeMeta) {
758 out.push_str(&format!(",s{},mt{}.{}", m.len(), m.mtime(), m.mtime_nsec()));
759 }
760 if class == ProbeClass::Meta {
761 out.push_str(&format!(",ct{}.{}", m.ctime(), m.ctime_nsec()));
762 }
763 out
764 }
765 };
766 format!("{PROBE_PREFIX}{}:{flag}:{payload}", class.token())
767}
768
769pub fn probe_fingerprint(path: &Path, follow: bool, class: ProbeClass) -> String {
770 probe_fingerprint_from(&observe_probe(path, follow, class), class, follow)
771}
772
773fn parse_probe_header(rest: &str) -> Option<(ProbeClass, bool)> {
774 let mut parts = rest.splitn(3, ':');
775 let class = ProbeClass::from_token(parts.next()?)?;
776 let follow = match parts.next()? {
777 "f" => true,
778 "n" => false,
779 _ => return None,
780 };
781 parts.next()?;
782 Some((class, follow))
783}
784
785pub fn stat_fingerprint(path: &Path) -> std::io::Result<String> {
791 let m = std::fs::metadata(path)?;
792 Ok(format!(
793 "{STAT_PREFIX}{}:{}:{}",
794 m.len(),
795 m.mtime(),
796 m.mtime_nsec()
797 ))
798}
799
800pub fn fingerprint_file(path: &Path, content_max: u64) -> std::io::Result<String> {
807 if std::fs::metadata(path)?.len() > content_max {
808 stat_fingerprint(path)
809 } else {
810 hash_file(path)
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817 use tempfile::TempDir;
818
819 fn cache(dir: &TempDir) -> LiveCache {
820 let store = crate::store::FileStore::open(dir.path().join("store")).unwrap();
821 LiveCache::new(store)
822 }
823
824 fn write_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf {
825 let p = dir.path().join(name);
826 std::fs::write(&p, content).unwrap();
827 p
828 }
829
830 fn root_for(p: &Path) -> FileRoot {
831 FileRoot {
832 path: p.to_path_buf(),
833 expected_hash: hash_file(p).unwrap(),
834 }
835 }
836
837 #[test]
838 fn miss_then_persist_then_hit() {
839 let dir = TempDir::new().unwrap();
840 let cache = cache(&dir);
841 let p = write_file(&dir, "a.txt", b"alpha");
842 let key = Key::from_bytes(b"read|a.txt|alpha");
843
844 assert_eq!(cache.lookup(&key).unwrap(), LookupOutcome::Miss);
845
846 cache
847 .persist(&key, b"alpha-formatted", "read", vec![root_for(&p)])
848 .unwrap();
849
850 match cache.lookup(&key).unwrap() {
851 LookupOutcome::Hit(payload) => {
852 assert_eq!(payload.bytes, b"alpha-formatted");
853 assert_eq!(payload.meta.tool_kind, "read");
854 }
855 other => panic!("expected Hit, got {other:?}"),
856 }
857 }
858
859 #[test]
860 fn revalidate_unchanged_returns_hit() {
861 let dir = TempDir::new().unwrap();
862 let cache = cache(&dir);
863 let p = write_file(&dir, "b.txt", b"beta");
864 let key = Key::from_bytes(b"read|b.txt|beta");
865 cache
866 .persist(&key, b"beta-formatted", "read", vec![root_for(&p)])
867 .unwrap();
868 match cache.lookup_revalidate(&key).unwrap() {
869 LookupOutcome::Hit(_) => {}
870 other => panic!("expected Hit, got {other:?}"),
871 }
872 }
873
874 #[test]
875 fn dir_root_revalidates_on_listing_and_invalidates_on_new_entry() {
876 let dir = TempDir::new().unwrap();
877 let cache = cache(&dir);
878 let listed = dir.path().join("listed");
879 std::fs::create_dir(&listed).unwrap();
880 write_file(&dir, "listed/a.txt", b"alpha");
881 let key = Key::from_bytes(b"exec|ls listed");
882 let root = FileRoot {
883 path: listed.clone(),
884 expected_hash: fingerprint_dir(&listed).unwrap(),
885 };
886 cache.persist(&key, b"a.txt\n", "exec", vec![root]).unwrap();
887
888 match cache.lookup_revalidate(&key).unwrap() {
889 LookupOutcome::Hit(_) => {}
890 other => panic!("unchanged listing must hit, got {other:?}"),
891 }
892
893 write_file(&dir, "listed/b.txt", b"bravo");
896 match cache.lookup_revalidate(&key).unwrap() {
897 LookupOutcome::Invalidated => {}
898 other => panic!("a new entry must invalidate, got {other:?}"),
899 }
900 }
901
902 #[test]
903 fn dir_root_of_a_removed_directory_invalidates() {
904 let dir = TempDir::new().unwrap();
905 let cache = cache(&dir);
906 let listed = dir.path().join("gone");
907 std::fs::create_dir(&listed).unwrap();
908 let key = Key::from_bytes(b"exec|ls gone");
909 let root = FileRoot {
910 path: listed.clone(),
911 expected_hash: fingerprint_dir(&listed).unwrap(),
912 };
913 cache.persist(&key, b"", "exec", vec![root]).unwrap();
914 std::fs::remove_dir(&listed).unwrap();
915 match cache.lookup_revalidate(&key).unwrap() {
916 LookupOutcome::Invalidated => {}
917 other => panic!("a vanished dir must invalidate, got {other:?}"),
918 }
919 }
920
921 #[test]
922 fn revalidate_modified_invalidates() {
923 let dir = TempDir::new().unwrap();
924 let cache = cache(&dir);
925 let p = write_file(&dir, "c.txt", b"charlie");
926 let key = Key::from_bytes(b"read|c.txt|charlie");
927 cache
928 .persist(&key, b"charlie-formatted", "read", vec![root_for(&p)])
929 .unwrap();
930
931 std::fs::write(&p, b"DELTA").unwrap();
932
933 match cache.lookup_revalidate(&key).unwrap() {
934 LookupOutcome::Invalidated => {}
935 other => panic!("expected Invalidated, got {other:?}"),
936 }
937 assert_eq!(cache.entry_count(), 0);
938 }
939
940 #[test]
941 fn revalidate_deleted_invalidates() {
942 let dir = TempDir::new().unwrap();
943 let cache = cache(&dir);
944 let p = write_file(&dir, "d.txt", b"delta");
945 let key = Key::from_bytes(b"read|d.txt|delta");
946 cache
947 .persist(&key, b"delta-formatted", "read", vec![root_for(&p)])
948 .unwrap();
949
950 std::fs::remove_file(&p).unwrap();
951
952 match cache.lookup_revalidate(&key).unwrap() {
953 LookupOutcome::Invalidated => {}
954 other => panic!("expected Invalidated, got {other:?}"),
955 }
956 }
957
958 #[test]
959 fn mark_dirty_drops_entry() {
960 let dir = TempDir::new().unwrap();
961 let cache = cache(&dir);
962 let p = write_file(&dir, "e.txt", b"echo");
963 let key = Key::from_bytes(b"read|e.txt|echo");
964 cache
965 .persist(&key, b"echo-formatted", "read", vec![root_for(&p)])
966 .unwrap();
967 assert_eq!(cache.entry_count(), 1);
968 cache.mark_dirty(&key);
969 assert_eq!(cache.entry_count(), 0);
970 assert_eq!(cache.lookup(&key).unwrap(), LookupOutcome::Miss);
971 }
972
973 #[test]
974 fn invalidate_path_drops_matching_entries() {
975 let dir = TempDir::new().unwrap();
976 let cache = cache(&dir);
977 let p1 = write_file(&dir, "f1.txt", b"foxtrot");
978 let p2 = write_file(&dir, "f2.txt", b"foxtrot2");
979 let k1 = Key::from_bytes(b"read|f1");
980 let k2 = Key::from_bytes(b"read|f2");
981 cache
982 .persist(&k1, b"f1-out", "read", vec![root_for(&p1)])
983 .unwrap();
984 cache
985 .persist(&k2, b"f2-out", "read", vec![root_for(&p2)])
986 .unwrap();
987 assert_eq!(cache.entry_count(), 2);
988 let n = cache.invalidate_path(&p1);
989 assert_eq!(n, 1);
990 assert_eq!(cache.entry_count(), 1);
991 match cache.lookup(&k2).unwrap() {
993 LookupOutcome::Hit(_) => {}
994 other => panic!("k2 should still hit, got {other:?}"),
995 }
996 match cache.lookup(&k1).unwrap() {
997 LookupOutcome::Miss => {}
998 other => panic!("k1 should miss, got {other:?}"),
999 }
1000 }
1001
1002 #[test]
1003 fn invalidate_path_matches_case_insensitively() {
1004 let dir = TempDir::new().unwrap();
1009 let cache = cache(&dir);
1010 let p = write_file(&dir, "CaseFile.txt", b"contents");
1011 let key = Key::from_bytes(b"read|casefile");
1012 cache
1013 .persist(&key, b"formatted", "read", vec![root_for(&p)])
1014 .unwrap();
1015 assert_eq!(cache.entry_count(), 1);
1016
1017 let differently_cased = dir.path().join("casefile.txt");
1018 let n = cache.invalidate_path(&differently_cased);
1019 assert_eq!(n, 1, "case-differing path must still invalidate the entry");
1020 assert_eq!(cache.entry_count(), 0);
1021 }
1022
1023 #[test]
1024 fn multi_root_revalidation() {
1025 let dir = TempDir::new().unwrap();
1026 let cache = cache(&dir);
1027 let p1 = write_file(&dir, "g1.txt", b"golf1");
1028 let p2 = write_file(&dir, "g2.txt", b"golf2");
1029 let key = Key::from_bytes(b"grep|pattern|g1+g2");
1030 cache
1031 .persist(
1032 &key,
1033 b"merged-output",
1034 "grep",
1035 vec![root_for(&p1), root_for(&p2)],
1036 )
1037 .unwrap();
1038
1039 match cache.lookup_revalidate(&key).unwrap() {
1041 LookupOutcome::Hit(_) => {}
1042 other => panic!("expected Hit, got {other:?}"),
1043 }
1044 std::fs::write(&p2, b"changed").unwrap();
1046 match cache.lookup_revalidate(&key).unwrap() {
1047 LookupOutcome::Invalidated => {}
1048 other => panic!("expected Invalidated, got {other:?}"),
1049 }
1050 }
1051
1052 #[test]
1053 fn upstream_invalidation_drops_dependents() {
1054 let dir = TempDir::new().unwrap();
1055 let cache = cache(&dir);
1056 let p = write_file(&dir, "src.txt", b"alpha");
1057 let read_key = Key::from_bytes(b"read|src");
1058 cache
1059 .persist(&read_key, b"alpha-formatted", "read", vec![root_for(&p)])
1060 .unwrap();
1061 let llm1 = Key::from_bytes(b"llm|first-prompt");
1063 let llm2 = Key::from_bytes(b"llm|second-prompt");
1064 cache
1065 .persist_with_upstreams(
1066 &llm1,
1067 b"completion-1",
1068 "llm_call",
1069 vec![],
1070 vec![read_key.clone()],
1071 )
1072 .unwrap();
1073 cache
1074 .persist_with_upstreams(
1075 &llm2,
1076 b"completion-2",
1077 "llm_call",
1078 vec![],
1079 vec![read_key.clone()],
1080 )
1081 .unwrap();
1082 assert_eq!(cache.entry_count(), 3);
1083
1084 let dropped = cache.invalidate_upstream(&read_key);
1086 assert_eq!(dropped, 2);
1087 assert_eq!(cache.lookup(&llm1).unwrap(), LookupOutcome::Miss);
1088 assert_eq!(cache.lookup(&llm2).unwrap(), LookupOutcome::Miss);
1089 }
1090
1091 #[test]
1092 fn invalidate_path_cascades_to_dependent_llm_calls() {
1093 let dir = TempDir::new().unwrap();
1094 let cache = cache(&dir);
1095 let p = write_file(&dir, "input.txt", b"hello");
1096 let read_key = Key::from_bytes(b"read|input");
1097 cache
1098 .persist(&read_key, b"hello-formatted", "read", vec![root_for(&p)])
1099 .unwrap();
1100 let llm = Key::from_bytes(b"llm|sees-read");
1101 cache
1102 .persist_with_upstreams(
1103 &llm,
1104 b"completion",
1105 "llm_call",
1106 vec![],
1107 vec![read_key.clone()],
1108 )
1109 .unwrap();
1110 assert_eq!(cache.entry_count(), 2);
1111
1112 std::fs::write(&p, b"changed").unwrap();
1114 let n = cache.invalidate_path(&p);
1115 assert_eq!(n, 1, "the read entry was the direct path match");
1116 assert_eq!(cache.lookup(&llm).unwrap(), LookupOutcome::Miss);
1118 assert_eq!(cache.entry_count(), 0);
1119 }
1120
1121 #[test]
1122 fn transitive_invalidation_walks_multi_hop_chain() {
1123 let dir = TempDir::new().unwrap();
1125 let cache = cache(&dir);
1126 let key_a = Key::from_bytes(b"a");
1127 let key_b = Key::from_bytes(b"b");
1128 let key_c = Key::from_bytes(b"c");
1129 let p = write_file(&dir, "f.txt", b"x");
1130 cache
1131 .persist(&key_a, b"a-bytes", "read", vec![root_for(&p)])
1132 .unwrap();
1133 cache
1134 .persist_with_upstreams(&key_b, b"b-bytes", "llm_call", vec![], vec![key_a.clone()])
1135 .unwrap();
1136 cache
1137 .persist_with_upstreams(&key_c, b"c-bytes", "llm_call", vec![], vec![key_b.clone()])
1138 .unwrap();
1139
1140 let dropped = cache.invalidate_upstream(&key_a);
1141 assert_eq!(dropped, 2);
1142 assert_eq!(cache.lookup(&key_b).unwrap(), LookupOutcome::Miss);
1143 assert_eq!(cache.lookup(&key_c).unwrap(), LookupOutcome::Miss);
1144 }
1145
1146 #[test]
1147 fn upstream_keys_persist_across_rehydration() {
1148 let dir = TempDir::new().unwrap();
1149 let p = write_file(&dir, "g.txt", b"data");
1150 let read_key = Key::from_bytes(b"read|g");
1151 let llm_key = Key::from_bytes(b"llm|g-consumer");
1152
1153 {
1154 let cache = cache(&dir);
1155 cache
1156 .persist(&read_key, b"data-formatted", "read", vec![root_for(&p)])
1157 .unwrap();
1158 cache
1159 .persist_with_upstreams(
1160 &llm_key,
1161 b"completion",
1162 "llm_call",
1163 vec![],
1164 vec![read_key.clone()],
1165 )
1166 .unwrap();
1167 }
1168
1169 let store_root = dir.path().join("store");
1172 let store2 = crate::store::FileStore::open(store_root).unwrap();
1173 let cache2 = LiveCache::new(store2);
1174 assert_eq!(cache2.entry_count(), 2);
1175 let dropped = cache2.invalidate_upstream(&read_key);
1176 assert_eq!(dropped, 1, "rehydrated edge must support cascade");
1177 }
1178
1179 #[test]
1180 fn cross_instance_file_edit_cascades_tool_and_llm() {
1181 let dir = TempDir::new().unwrap();
1187 let f = write_file(&dir, "dep.txt", b"v1");
1188 let content = b"TOOL: contents of dep.txt";
1189 let tkey = tool_result_key(content);
1190 let llm_key = Key::from_bytes(b"llm|consumed-the-tool-result");
1191
1192 {
1193 let producer = cache(&dir);
1194 producer
1195 .persist(&tkey, content, "tool_result", vec![root_for(&f)])
1196 .unwrap();
1197 producer
1198 .persist_with_upstreams(
1199 &llm_key,
1200 b"completion-bytes",
1201 "llm_call",
1202 vec![],
1203 vec![tkey.clone()],
1204 )
1205 .unwrap();
1206 }
1207
1208 {
1209 let editor = cache(&dir);
1210 std::fs::write(&f, b"v2-changed").unwrap();
1211 let n = editor.invalidate_path(&f);
1212 assert!(
1213 n >= 1,
1214 "the tool node depending on the file must be dropped"
1215 );
1216 }
1217
1218 let reader = cache(&dir);
1219 assert!(
1220 matches!(reader.lookup(&tkey).unwrap(), LookupOutcome::Miss),
1221 "tool result node must be gone cross-instance"
1222 );
1223 assert!(
1224 matches!(reader.lookup(&llm_key).unwrap(), LookupOutcome::Miss),
1225 "the dependent LLM completion must be gone cross-instance"
1226 );
1227 }
1228
1229 #[test]
1230 fn fresh_cache_rehydrates_from_store_on_disk() {
1231 let dir = TempDir::new().unwrap();
1236 let p = write_file(&dir, "rehydrate.txt", b"persist me");
1237 let key = Key::from_bytes(b"read|rehydrate|persist me");
1238
1239 {
1240 let cache = cache(&dir);
1241 cache
1242 .persist(&key, b"served-once", "read", vec![root_for(&p)])
1243 .unwrap();
1244 assert_eq!(cache.entry_count(), 1);
1245 } let store_root = dir.path().join("store");
1248 let store2 = crate::store::FileStore::open(store_root).unwrap();
1249 let cache2 = LiveCache::new(store2);
1250 assert_eq!(cache2.entry_count(), 1);
1253 match cache2.lookup_revalidate(&key).unwrap() {
1254 LookupOutcome::Hit(payload) => assert_eq!(payload.bytes, b"served-once"),
1255 other => panic!("expected Hit after rehydrate, got {other:?}"),
1256 }
1257 }
1258
1259 #[test]
1260 fn hit_returns_byte_identical_payload() {
1261 let dir = TempDir::new().unwrap();
1265 let cache = cache(&dir);
1266 let p = write_file(&dir, "h.txt", b"hotel");
1267 let key = Key::from_bytes(b"read|h");
1268 let original = b" 1\thotel-formatted-with-line-numbers\n 2\tetc\n";
1269 cache
1270 .persist(&key, original, "read", vec![root_for(&p)])
1271 .unwrap();
1272 match cache.lookup_revalidate(&key).unwrap() {
1273 LookupOutcome::Hit(p) => assert_eq!(p.bytes, original),
1274 other => panic!("expected Hit, got {other:?}"),
1275 }
1276 }
1277
1278 #[test]
1279 fn hash_file_with_limit_content_hashes_within_limit() {
1280 let dir = TempDir::new().unwrap();
1281 let p = write_file(&dir, "small.bin", b"comfortably within the limit");
1282 match hash_file_with_limit(&p, 1024).unwrap() {
1283 FileHash::Content(h) => assert_eq!(h, hash_file(&p).unwrap()),
1284 FileHash::Oversized => panic!("a file within the limit must content-hash"),
1285 }
1286 }
1287
1288 #[test]
1289 fn hash_file_with_limit_reports_oversized_above_limit() {
1290 let dir = TempDir::new().unwrap();
1291 let p = write_file(&dir, "big.bin", &[7u8; 4096]);
1292 assert_eq!(hash_file_with_limit(&p, 64).unwrap(), FileHash::Oversized);
1293 }
1294
1295 #[test]
1296 fn oversized_files_yield_no_keyable_digest() {
1297 let dir = TempDir::new().unwrap();
1302 let a = write_file(&dir, "a.bin", &[1u8; 4096]);
1303 let b = write_file(&dir, "b.bin", &[2u8; 4096]);
1304 let ha = hash_file_with_limit(&a, 64).unwrap();
1305 let hb = hash_file_with_limit(&b, 64).unwrap();
1306 assert_eq!(ha, FileHash::Oversized);
1307 assert_eq!(hb, FileHash::Oversized);
1308 assert!(ha.content().is_none() && hb.content().is_none());
1309 }
1310
1311 const ALL_CLASSES: [ProbeClass; 5] = [
1312 ProbeClass::Meta,
1313 ProbeClass::PreChangeMeta,
1314 ProbeClass::Kind,
1315 ProbeClass::Link,
1316 ProbeClass::Exists,
1317 ];
1318
1319 fn stat_classes() -> [ProbeClass; 3] {
1320 [
1321 ProbeClass::Meta,
1322 ProbeClass::PreChangeMeta,
1323 ProbeClass::Kind,
1324 ]
1325 }
1326
1327 fn fp(p: &Path, class: ProbeClass) -> String {
1328 probe_fingerprint(p, true, class)
1329 }
1330
1331 fn bump_ctime(p: &Path) {
1332 let before = std::fs::symlink_metadata(p).unwrap();
1333 for _ in 0..200 {
1334 std::thread::sleep(std::time::Duration::from_millis(5));
1335 let perms = std::fs::metadata(p).unwrap().permissions();
1336 std::fs::set_permissions(p, perms).unwrap();
1337 let after = std::fs::symlink_metadata(p).unwrap();
1338 if (after.ctime(), after.ctime_nsec()) != (before.ctime(), before.ctime_nsec()) {
1339 return;
1340 }
1341 }
1342 panic!("ctime never advanced");
1343 }
1344
1345 #[test]
1346 fn probe_round_trips_for_every_class_and_follow() {
1347 let dir = TempDir::new().unwrap();
1348 let file = write_file(&dir, "f", b"x");
1349 let link = dir.path().join("l");
1350 std::os::unix::fs::symlink(&file, &link).unwrap();
1351 let missing = dir.path().join("missing");
1352 for path in [&file, &link, &missing] {
1353 for class in ALL_CLASSES {
1354 for follow in [true, false] {
1355 let direct = probe_fingerprint(path, follow, class);
1356 let via =
1357 probe_fingerprint_from(&observe_probe(path, follow, class), class, follow);
1358 assert_eq!(direct, via);
1359 assert!(direct.starts_with(PROBE_PREFIX));
1360 assert_eq!(current_root_fingerprint(path, &direct).unwrap(), direct);
1361 }
1362 }
1363 }
1364 }
1365
1366 #[test]
1367 fn probe_missing_path_is_ok_and_ends_in_err2() {
1368 let dir = TempDir::new().unwrap();
1369 let missing = dir.path().join("missing");
1370 let f = fp(&missing, ProbeClass::Meta);
1371 assert_eq!(f, "probe:meta:f:err2");
1372 assert_eq!(current_root_fingerprint(&missing, &f).unwrap(), f);
1373 assert!(probe_fingerprint(&missing, false, ProbeClass::Link).ends_with("err2"));
1374 }
1375
1376 #[test]
1377 fn probe_changes_when_path_appears_disappears_or_changes_type() {
1378 let dir = TempDir::new().unwrap();
1379 let p = dir.path().join("p");
1380 for class in stat_classes() {
1381 let missing = fp(&p, class);
1382 std::fs::write(&p, b"x").unwrap();
1383 let file = fp(&p, class);
1384 assert_ne!(missing, file);
1385 std::fs::remove_file(&p).unwrap();
1386 assert_eq!(fp(&p, class), missing);
1387 std::fs::create_dir(&p).unwrap();
1388 let as_dir = fp(&p, class);
1389 assert_ne!(as_dir, file);
1390 assert_ne!(as_dir, missing);
1391 std::fs::remove_dir(&p).unwrap();
1392 }
1393 }
1394
1395 #[test]
1396 fn probe_changes_on_mode_inode_and_link_count() {
1397 use std::os::unix::fs::PermissionsExt;
1398 let dir = TempDir::new().unwrap();
1399 let p = write_file(&dir, "p", b"x");
1400 for class in stat_classes() {
1401 let base = fp(&p, class);
1402 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
1403 let chmoded = fp(&p, class);
1404 assert_ne!(base, chmoded);
1405 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
1406
1407 let tmp = write_file(&dir, "tmp", b"x");
1408 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
1409 let before = fp(&p, ProbeClass::Kind);
1410 std::fs::rename(&tmp, &p).unwrap();
1411 assert_ne!(before, fp(&p, ProbeClass::Kind));
1412
1413 let with_one = fp(&p, class);
1414 let extra = dir.path().join("extra");
1415 std::fs::hard_link(&p, &extra).unwrap();
1416 assert_ne!(with_one, fp(&p, class));
1417 std::fs::remove_file(&extra).unwrap();
1418 }
1419 }
1420
1421 #[test]
1422 fn an_exists_fingerprint_moves_only_with_existence_and_kind() {
1423 let dir = TempDir::new().unwrap();
1424 let p = write_file(&dir, "p", b"x");
1425 let present = fp(&p, ProbeClass::Exists);
1426
1427 filetime::set_file_mtime(&p, filetime::FileTime::from_unix_time(2_000_000, 7)).unwrap();
1428 std::fs::write(&p, b"longer content").unwrap();
1429 let other = write_file(&dir, "other", b"y");
1430 std::fs::rename(&other, &p).unwrap();
1431 assert_eq!(present, fp(&p, ProbeClass::Exists));
1432
1433 std::fs::remove_file(&p).unwrap();
1434 let absent = fp(&p, ProbeClass::Exists);
1435 assert_ne!(present, absent);
1436
1437 std::fs::create_dir(&p).unwrap();
1438 let as_dir = fp(&p, ProbeClass::Exists);
1439 assert_ne!(present, as_dir);
1440 assert_ne!(absent, as_dir);
1441 }
1442
1443 #[test]
1444 fn probe_size_and_mtime_move_meta_and_pre_but_not_kind() {
1445 let dir = TempDir::new().unwrap();
1446 let p = write_file(&dir, "p", b"x");
1447 filetime::set_file_mtime(&p, filetime::FileTime::from_unix_time(1_000_000, 0)).unwrap();
1448 let meta = fp(&p, ProbeClass::Meta);
1449 let pre = fp(&p, ProbeClass::PreChangeMeta);
1450 let kind = fp(&p, ProbeClass::Kind);
1451
1452 filetime::set_file_mtime(&p, filetime::FileTime::from_unix_time(2_000_000, 7)).unwrap();
1453 assert_ne!(meta, fp(&p, ProbeClass::Meta));
1454 assert_ne!(pre, fp(&p, ProbeClass::PreChangeMeta));
1455 assert_eq!(kind, fp(&p, ProbeClass::Kind));
1456
1457 let meta = fp(&p, ProbeClass::Meta);
1458 let pre = fp(&p, ProbeClass::PreChangeMeta);
1459 std::fs::OpenOptions::new()
1460 .append(true)
1461 .open(&p)
1462 .unwrap()
1463 .set_len(100)
1464 .unwrap();
1465 filetime::set_file_mtime(&p, filetime::FileTime::from_unix_time(2_000_000, 7)).unwrap();
1466 assert_ne!(meta, fp(&p, ProbeClass::Meta));
1467 assert_ne!(pre, fp(&p, ProbeClass::PreChangeMeta));
1468 assert_eq!(kind, fp(&p, ProbeClass::Kind));
1469 }
1470
1471 #[test]
1472 fn probe_ctime_moves_meta_only() {
1473 let dir = TempDir::new().unwrap();
1474 let p = write_file(&dir, "p", b"x");
1475 let meta = fp(&p, ProbeClass::Meta);
1476 let pre = fp(&p, ProbeClass::PreChangeMeta);
1477 let kind = fp(&p, ProbeClass::Kind);
1478 bump_ctime(&p);
1479 assert_ne!(meta, fp(&p, ProbeClass::Meta));
1480 assert_eq!(pre, fp(&p, ProbeClass::PreChangeMeta));
1481 assert_eq!(kind, fp(&p, ProbeClass::Kind));
1482 }
1483
1484 #[test]
1485 fn probe_link_class_tracks_the_target() {
1486 let dir = TempDir::new().unwrap();
1487 let link = dir.path().join("l");
1488 std::os::unix::fs::symlink("one", &link).unwrap();
1489 let one = probe_fingerprint(&link, false, ProbeClass::Link);
1490 std::fs::remove_file(&link).unwrap();
1491 std::os::unix::fs::symlink("two", &link).unwrap();
1492 assert_ne!(one, probe_fingerprint(&link, false, ProbeClass::Link));
1493 let file = write_file(&dir, "f", b"x");
1494 assert_ne!(
1495 probe_fingerprint(&file, false, ProbeClass::Link),
1496 probe_fingerprint(&link, false, ProbeClass::Link)
1497 );
1498 }
1499
1500 #[test]
1501 fn probe_follow_is_respected() {
1502 let dir = TempDir::new().unwrap();
1503 let a = write_file(&dir, "a", b"x");
1504 let b = write_file(&dir, "b", b"x");
1505 let link = dir.path().join("l");
1506 std::os::unix::fs::symlink(&a, &link).unwrap();
1507 let followed = probe_fingerprint(&link, true, ProbeClass::Kind);
1508 let unfollowed = probe_fingerprint(&link, false, ProbeClass::Kind);
1509 assert!(followed.starts_with("probe:kind:f:t100000,"));
1510 assert!(unfollowed.starts_with("probe:kind:n:t120000,"));
1511 std::fs::remove_file(&link).unwrap();
1512 std::os::unix::fs::symlink(&b, &link).unwrap();
1513 assert_ne!(followed, probe_fingerprint(&link, true, ProbeClass::Kind));
1514 }
1515
1516 #[test]
1517 fn probe_class_observation_mismatch_never_matches_live() {
1518 let dir = TempDir::new().unwrap();
1519 let p = write_file(&dir, "p", b"x");
1520 let stat = observe_probe(&p, true, ProbeClass::Kind);
1521 let bad = probe_fingerprint_from(&stat, ProbeClass::Link, false);
1522 assert!(bad.ends_with(":invalid"));
1523 assert_ne!(bad, probe_fingerprint(&p, false, ProbeClass::Link));
1524 let link = observe_probe(&p, false, ProbeClass::Link);
1525 let bad = probe_fingerprint_from(&link, ProbeClass::Kind, true);
1526 assert!(bad.ends_with(":invalid"));
1527 }
1528
1529 #[test]
1530 fn unparseable_probe_expected_is_an_error() {
1531 let dir = TempDir::new().unwrap();
1532 let p = write_file(&dir, "p", b"x");
1533 for bad in ["probe:", "probe:bogus:f:x", "probe:meta:q:x", "probe:meta"] {
1534 let err = current_root_fingerprint(&p, bad).unwrap_err();
1535 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1536 }
1537 }
1538
1539 #[test]
1540 fn fingerprint_dir_changes_when_file_becomes_same_name_directory() {
1541 let dir = TempDir::new().unwrap();
1542 let listed = dir.path().join("listed");
1543 std::fs::create_dir(&listed).unwrap();
1544 std::fs::write(listed.join("x"), b"x").unwrap();
1545 let as_file = fingerprint_dir(&listed).unwrap();
1546 std::fs::remove_file(listed.join("x")).unwrap();
1547 std::fs::create_dir(listed.join("x")).unwrap();
1548 let as_dir = fingerprint_dir(&listed).unwrap();
1549 assert_ne!(as_file, as_dir);
1550 assert!(as_dir.starts_with(DIR_PREFIX));
1551 }
1552
1553 #[test]
1554 fn probe_root_on_missing_path_invalidates_once_created() {
1555 let dir = TempDir::new().unwrap();
1556 let cache = cache(&dir);
1557 let p = dir.path().join("later");
1558 let key = Key::from_bytes(b"exec|stat later");
1559 let root = FileRoot {
1560 path: p.clone(),
1561 expected_hash: probe_fingerprint(&p, true, ProbeClass::Meta),
1562 };
1563 cache
1564 .persist(&key, b"missing\n", "exec", vec![root])
1565 .unwrap();
1566 match cache.lookup_revalidate(&key).unwrap() {
1567 LookupOutcome::Hit(_) => {}
1568 other => panic!("unchanged probe must hit, got {other:?}"),
1569 }
1570 std::fs::write(&p, b"now here").unwrap();
1571 match cache.lookup_revalidate(&key).unwrap() {
1572 LookupOutcome::Invalidated => {}
1573 other => panic!("expected Invalidated, got {other:?}"),
1574 }
1575 }
1576}