Skip to main content

verdant_runtime/
cache.rs

1//! `LiveCache` — the M1 cache surface that the MCP server consumes.
2//!
3//! Keying philosophy: every cache entry is keyed by a deterministic blake3
4//! hash of the tool's *inputs*, where "inputs" includes any file content
5//! the tool's output is a function of. The store payload is the exact
6//! formatted bytes the MCP tool fed back to the model on the first
7//! execution. A subsequent identical call hits when (a) the input hash
8//! matches AND (b) every recorded file root revalidates clean against the
9//! current filesystem. If either fails, the entry is treated as invalid
10//! and the registered metadata is removed so a stale entry does not
11//! linger. M1 keeps the registry in memory; M2 will persist it.
12//!
13//! The cache surface is deliberately tool-agnostic: callers compute the
14//! input bytes (we provide canonicalization helpers in `key`), invoke
15//! `lookup` or `lookup_revalidate`, and on miss they execute the real
16//! tool and call `persist`. The cache does not run tools itself; that
17//! lives one layer up in `verdant-mcp`.
18
19use 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/// One file dependency of a cache entry. The tool computed its output as a
34/// function of (path, contents at expected_hash). On every green hit we
35/// re-blake3 the file and require the hash to still match; if it does not,
36/// the entry is invalidated.
37#[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 used to resolve workspace-relative `FileRoot::path`
53    /// during revalidation. `FileRoot` paths are stored relative so a
54    /// cache entry persisted on Alice's machine at `/home/alice/repo/`
55    /// is reusable on Bob's machine at `/home/bob/work/repo/` without
56    /// changing the cache key. `LiveCache::new` defaults the base to
57    /// the process cwd at construction time; binaries that know the
58    /// real project root should call `LiveCache::with_workspace`.
59    workspace_base: PathBuf,
60}
61
62#[derive(Debug, Clone, PartialEq)]
63pub enum LookupOutcome {
64    /// Cache hit. The payload is byte-for-byte the same as the original
65    /// execution and (for revalidating lookups) every file root has been
66    /// confirmed unchanged.
67    Hit(Payload),
68    /// No entry for this key.
69    Miss,
70    /// Entry existed but a file root has changed; the entry has been
71    /// removed from the registry so subsequent lookups behave as Miss
72    /// without paying the revalidation cost again.
73    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        // Rehydrate the in-memory registry from on-disk meta files so a
96        // freshly constructed cache (e.g., a brand-new MCP server
97        // process started by Claude Code on each `claude -p` invocation)
98        // can serve previously-persisted entries instead of treating
99        // every key as Miss. Without this, M1's cross-session cache
100        // doesn't actually exist — every Run-2 lookup would miss,
101        // overwrite the same payload file, and the cache would provide
102        // zero savings between sessions.
103        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    /// Whether an entry for `key` is present, checking the in-memory registry
146    /// first and falling back to the store. Does not fetch or revalidate the
147    /// payload, so it is a cheap presence probe (used by provenance telemetry
148    /// to count how many tool-result edges in a prompt resolve to a known node).
149    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    /// Bare lookup with no file revalidation. Used by tools whose output
162    /// has no filesystem dependency (rare in M1 — even Bash depends on
163    /// the cwd's contents in practice). Most callers want
164    /// `lookup_revalidate`.
165    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                // Backends like `RemoteStore` cannot pre-populate the
174                // registry through `iter_meta` because there is no bulk
175                // listing over the wire; the registry stays empty and
176                // entries are discovered one round-trip at a time. Seed
177                // the registry from the payload meta so subsequent
178                // operations (invalidate_path, invalidate_upstream,
179                // entry_count) see the entry without another round-trip.
180                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                    // Registry says we have it but the store does not —
188                    // happens if the store was truncated externally
189                    // between persist and lookup, or if a `_shared`
190                    // entry the registry knows about was server-side
191                    // invalidated. Drop the orphan so subsequent
192                    // lookups return Miss directly.
193                    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    /// Lookup with revalidation: re-blake3 every recorded file root and
204    /// require the hash to still match the value captured on persist. On
205    /// any mismatch the entry is removed from the registry and `Invalidated`
206    /// is returned so the caller knows to re-execute the real tool.
207    ///
208    /// This is the primary lookup path for `read`, `glob`, and `grep`
209    /// tools whose output is a pure function of named file contents. Bash
210    /// typically cannot use this path because the set of files Bash
211    /// reads is not known a priori.
212    pub fn lookup_revalidate(&self, key: &Key) -> Result<LookupOutcome, CacheError> {
213        // Snapshot the metadata under a read lock, drop the lock before
214        // doing any I/O so we don't hold it across blake3 of large files,
215        // then upgrade to a write lock only if invalidation is required.
216        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        // Fast path: registry already knows the roots. Revalidate them
222        // first to avoid a round-trip to a slow store on a dirty entry.
223        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 we did not have the entry in the registry, the
239                // store's payload meta carries the file roots the
240                // entry was persisted with. Revalidate against the
241                // local filesystem before trusting it — this is the
242                // cross-machine drift check that RemoteStore-backed
243                // caches rely on, because the server only knows its
244                // own filesystem and cannot detect that Bob's local
245                // checkout has diverged from Alice's.
246                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    /// Record a fresh tool execution. Caller has already produced the
302    /// formatted output bytes the model will see; we persist them under
303    /// `key` and register the file roots for future revalidation.
304    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    /// Persist an entry whose validity depends on the listed upstream
315    /// cache keys. The proxy's LlmCall path uses this so a tool-cache
316    /// invalidation can walk the upstream edge and drop dependent
317    /// completions.
318    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    /// Drop the registry entry for `key`. The store payload remains on
350    /// disk (M1 is append-only; M2 adds eviction) but subsequent lookups
351    /// will Miss because the registry is the authoritative gate.
352    ///
353    /// Used by `write` and `edit` MCP tools to invalidate every cached
354    /// node whose path matches the written path; the verdant-mcp layer
355    /// computes the affected key set and calls this for each.
356    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        // Best-effort store cleanup; if it fails (concurrent writer,
362        // permissions glitch) we silently leave the bytes on disk
363        // because the registry is the authoritative gate and a stale
364        // payload that no entry references is harmless.
365        let _ = self.store.remove(key);
366    }
367
368    /// Drop every cache entry that depends on `upstream_key` either
369    /// directly (its `upstream_keys` includes that hex) or transitively
370    /// (its dependency closure does). Returns the number of entries
371    /// dropped.
372    ///
373    /// This is the cross-layer dirty propagation path that ties the
374    /// M3 LlmCall cache to M1's tool cache: when a `read` entry is
375    /// invalidated by `invalidate_path`, the proxy calls this with
376    /// the read's key and every LlmCall whose prompt consumed that
377    /// read drops out of the cache. Without this hop, an edited file
378    /// would silently feed the model the old bytes via a stale cached
379    /// completion.
380    pub fn invalidate_upstream(&self, upstream_key: &Key) -> usize {
381        // Walk the PERSISTED metadata, not just the in-memory registry, so the
382        // cascade crosses process boundaries: a tool process invalidating a
383        // file must drop the LLM-call entries a separate proxy process persisted
384        // into the shared store. The registry is only a per-process perf cache;
385        // `lookup` already trusts the store as the source of truth.
386        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    /// Drop every cached entry whose recorded file roots include `path`, then
421    /// cascade up the dependency edges. Scans the persisted store metadata so
422    /// the effect is visible across processes sharing the store. O(n) in store
423    /// size; the code anticipates a future path -> keys index.
424    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        // Compare a lowercased form so an edit recorded under one casing
430        // still invalidates an entry recorded under another. On a
431        // case-insensitive filesystem `Src/Foo.rs` and `src/foo.rs` are
432        // one file; a missed invalidation leaves a stale hit, while a
433        // spurious extra invalidation only costs a recompute, so the
434        // conservative lowercased comparison is applied unconditionally.
435        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            // Cascade up the dependency edge so any LlmCall whose prompt
464            // consumed this tool result also drops.
465            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
484/// Compute the blake3 hex digest of the file at `path`. Used both to
485/// record `FileRoot::expected_hash` on persist and to revalidate on
486/// lookup.
487enum RevalidationOutcome {
488    Ok,
489    Invalidated,
490}
491
492/// Fingerprints `path` the way revalidation does for a root recorded as
493/// `expected`: a `dir:` prefix picks a directory listing, `stat:` a size+mtime
494/// fingerprint (never re-read), `probe:` a path-lookup fingerprint, anything else a blake3 content digest.
495pub 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
539/// Join a workspace base with a recorded `FileRoot::path`. On Unix,
540/// `PathBuf::join` replaces the base when the argument is absolute,
541/// so this also handles legacy entries persisted with absolute paths
542/// (single-machine M4) without breaking. New entries persist
543/// workspace-relative paths and resolve through the base; cross-user
544/// `_shared` entries work because every consumer joins against its
545/// own workspace.
546fn resolve_root_path(workspace_base: &Path, recorded: &Path) -> PathBuf {
547    workspace_base.join(recorded)
548}
549
550/// Lowercased lossy string form of a path, used as a case-insensitive
551/// comparison key for path invalidation.
552fn lower_path(p: &Path) -> String {
553    p.to_string_lossy().to_lowercase()
554}
555
556/// Default ceiling for content hashing. A file larger than this is
557/// reported `FileHash::Oversized` and is therefore uncacheable, because
558/// (see `FileHash`) a size/mtime fingerprint is not a safe substitute
559/// for a content hash.
560const HASH_MAX_BYTES: u64 = 100 * 1024 * 1024;
561
562/// The content-hash ceiling, read from `$VERDANT_HASH_MAX_BYTES` if set
563/// and parseable, otherwise `HASH_MAX_BYTES`.
564pub 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/// Outcome of fingerprinting a file for cache keying.
572#[derive(Debug, Clone, PartialEq, Eq)]
573pub enum FileHash {
574    /// blake3 content digest, hex-encoded.
575    Content(String),
576    /// File is larger than the content-hash ceiling. It carries no
577    /// digest on purpose: a size/mtime fingerprint would collide two
578    /// different files with equal size and mtime onto one key, so a
579    /// tool whose output depends on an oversized file must not cache.
580    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
592/// blake3 hex digest of the file at `path`, streamed so memory stays
593/// bounded regardless of file size. Always content-hashes; callers that
594/// must not cache oversized files use `hash_file_with_limit` instead.
595pub 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
609/// Fingerprint `path` for cache keying. A file at or below `max` bytes is
610/// content-hashed; a larger file is reported `Oversized` so the caller
611/// declines to cache rather than keying on a collision-prone fingerprint.
612pub 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
619/// Content-addressed identity of a tool result. The MCP tool layer registers a
620/// node under this key carrying the file roots the result depended on, and the
621/// proxy records the same key as an upstream edge of any LLM call whose prompt
622/// embedded that result, so editing one of those files cascades file -> this
623/// node -> the dependent completions. Both layers must hash identically, so the
624/// function lives here in the shared runtime crate.
625pub 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
635/// Fingerprint a directory's entry list: blake3 over the entries sorted by
636/// name, each framed as its length-prefixed name plus a one-byte type (regular
637/// file, directory, symlink, other) from `DirEntry::file_type`, so replacing a
638/// file with a same-name directory changes the digest. Entry contents and
639/// other metadata do not participate: each file a command then read is
640/// fingerprinted separately. The `dir:` prefix tells `revalidate_file_roots`
641/// to recompute this instead of hashing file bytes.
642pub 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    /// Existence and file type only, for temp names whose other state is
677    /// per-run.
678    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/// What a probe saw: `Err` holds the raw errno of the failed lookup.
705#[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
715/// `follow` is ignored for `ProbeClass::Link`, which never follows.
716pub 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
730/// A class/observation mismatch yields the payload `invalid`, which no live
731/// fingerprint produces.
732pub 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
785/// A cheap size+mtime fingerprint for a file, used for large/stable inputs
786/// (compilers, system libraries) that would be prohibitively expensive to
787/// content-hash on every cache validation. Soundness assumption: identical
788/// (size, nanosecond mtime) implies identical content, the same assumption
789/// build tools already make for their own incrementality.
790pub 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
800/// Fingerprint a read-set file: content-hash it if it is at or below
801/// `content_max` (sound), otherwise fall back to a size+mtime fingerprint so a
802/// huge stable input does not block caching or cost a full re-read on every
803/// validation. The returned string is what `revalidate_file_roots` recomputes
804/// and compares; its form (bare hex vs `stat:` prefix) tells revalidation which
805/// mode to use.
806pub 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        // Renaming or adding an entry changes the name list, not any file
894        // the command read, so only the dir fingerprint can catch it.
895        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        // k1 invalidated, k2 still present
992        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        // On a case-insensitive filesystem `Src/Foo.rs` and `src/foo.rs`
1005        // name the same file; a path edit reported under one casing must
1006        // still invalidate an entry whose file root was recorded under
1007        // another. A missed invalidation leaves a stale hit.
1008        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        // First revalidation: clean
1040        match cache.lookup_revalidate(&key).unwrap() {
1041            LookupOutcome::Hit(_) => {}
1042            other => panic!("expected Hit, got {other:?}"),
1043        }
1044        // Modify only the second root: must invalidate
1045        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        // Two LlmCalls both depend on the read result.
1062        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        // Invalidating the read key must cascade to both LlmCalls.
1085        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        // Modify the file and invalidate by path.
1113        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        // The LlmCall must also be gone via the cascade.
1117        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        // A -> B -> C: invalidating A drops B and C.
1124        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        // Fresh cache pointed at the same store: the upstream edge
1170        // must come back so a subsequent invalidation cascades.
1171        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        // The production scenario: a tool process and a proxy process share one
1182        // store. Editing a file in a THIRD instance must drop both the tool
1183        // result node (file dependency) and the LLM completion that consumed it,
1184        // visible to a fresh instance. Proves cross-process incremental agent
1185        // reasoning via the shared store, not an in-memory registry.
1186        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        // M1's whole point: a process restart must not invalidate the
1232        // cache. Persist via one cache instance, drop it, build a fresh
1233        // cache pointed at the same store directory, and confirm the
1234        // entry is still served as a Hit.
1235        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        } // drop cache; in-memory registry destroyed.
1246
1247        let store_root = dir.path().join("store");
1248        let store2 = crate::store::FileStore::open(store_root).unwrap();
1249        let cache2 = LiveCache::new(store2);
1250        // Without rehydration this would be 0 and the next lookup would
1251        // miss, defeating the entire cross-session caching story.
1252        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        // Critical correctness test: cache must hand back the exact bytes
1262        // it persisted, not a re-formatted view. A divergence here would
1263        // silently corrupt the model's view of the world.
1264        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        // The removed metadata fallback hashed (path, size, mtime), so two
1298        // distinct oversized files with equal size and mtime collided onto
1299        // one key. FileHash::Oversized carries no digest, so distinct
1300        // oversized files cannot be keyed against each other at all.
1301        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}