Skip to main content

rto_graph/
cache.rs

1//! Content-addressed on-disk cache of per-blob [`FactSet`]s.
2//!
3//! The cache is a simple content-addressed key→[`FactSet`] store; the caller
4//! derives the key (see [`crate::sync`], which keys by blob oid **and** path,
5//! because extraction is a pure function of both). The cache lives under the
6//! repository's *common* git directory (e.g. `<common>/roteiro/objects/`), so
7//! all worktrees and branches that share a key share its extracted facts.
8//! Entries are JSON, sharded by the first two characters of the key (git-style)
9//! to keep directories small.
10
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use crate::FactSet;
15
16/// Errors raised by the object cache.
17#[derive(Debug, thiserror::Error)]
18pub enum CacheError {
19    /// Filesystem failure.
20    #[error("cache io error: {0}")]
21    Io(#[from] std::io::Error),
22    /// A cached entry could not be (de)serialized.
23    #[error("cache json error: {0}")]
24    Json(#[from] serde_json::Error),
25}
26
27/// What one [`ObjectCache::sweep`] pass did.
28///
29/// `scanned` is exactly `retained + removed + raced + failed`, and everything
30/// under the root that is not an entry lands in `skipped` instead — so a sweep
31/// that had nothing to do and a sweep that could not do it do not print the same
32/// line.
33#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
34pub struct ObjectSweep {
35    /// Cache entries examined — every `<shard>/<rest>.json` under the root.
36    pub scanned: usize,
37    /// Entries `retain` kept.
38    pub retained: usize,
39    /// Entries `retain` rejected and this pass deleted.
40    pub removed: usize,
41    /// Bytes still held by the `retained` entries.
42    pub retained_bytes: u64,
43    /// Bytes freed — the size of the `removed` entries before deletion.
44    pub freed_bytes: u64,
45    /// Rejected entries already gone when the delete ran: another process swept
46    /// the same shared cache concurrently. Counted rather than raised, because
47    /// two sweeps agreeing is the expected outcome, not a fault.
48    pub raced: usize,
49    /// Rejected entries that could not be deleted — a permission problem, or a
50    /// platform that refuses to unlink a file another process holds open.
51    /// Counted and reported rather than aborting: a sweep that stops at the first
52    /// stuck file both reclaims less and says nothing about why.
53    pub failed: usize,
54    /// Files under the root that are **not** entries: a
55    /// `.json.tmp.<pid>-<nanos>-<seq>` from a [`ObjectCache::put`] still in flight
56    /// (the third field is a process-wide counter, without which two threads
57    /// choose one temp name), or anything a later format
58    /// puts here. Never shown to `retain` and never deleted — a sweep that
59    /// guesses at a name it does not recognise is a sweep that deletes another
60    /// process's half-written work.
61    pub skipped: usize,
62}
63
64/// A content-addressed store of fact sets on disk.
65pub struct ObjectCache {
66    root: PathBuf,
67}
68
69impl ObjectCache {
70    /// Open (creating if absent) a cache rooted at `root`.
71    ///
72    /// # Errors
73    /// Returns [`CacheError::Io`] if the root directory cannot be created.
74    pub fn open(root: impl Into<PathBuf>) -> Result<Self, CacheError> {
75        let root = root.into();
76        fs::create_dir_all(&root)?;
77        Ok(Self { root })
78    }
79
80    /// The directory this cache stores objects under.
81    #[must_use]
82    pub fn root(&self) -> &Path {
83        &self.root
84    }
85
86    fn path_for(&self, blob_id: &str) -> PathBuf {
87        // Shard by the first two characters, like git's `objects/ab/cdef…`.
88        let (shard, rest) = blob_id.split_at(blob_id.len().min(2));
89        self.root.join(shard).join(format!("{rest}.json"))
90    }
91
92    /// Whether a fact set is cached for `blob_id`.
93    #[must_use]
94    pub fn contains(&self, blob_id: &str) -> bool {
95        self.path_for(blob_id).exists()
96    }
97
98    /// Load the cached fact set for `blob_id`, if present.
99    ///
100    /// # Errors
101    /// Returns [`CacheError::Io`] on read failure or [`CacheError::Json`] if the
102    /// entry cannot be decoded.
103    pub fn get(&self, blob_id: &str) -> Result<Option<FactSet>, CacheError> {
104        let path = self.path_for(blob_id);
105        match fs::read(&path) {
106            Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
107            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
108            Err(e) => Err(e.into()),
109        }
110    }
111
112    /// Store `facts` under `blob_id`, replacing any existing entry. The write is
113    /// atomic (write-to-temp then rename) so a crash never leaves a torn entry.
114    ///
115    /// # Errors
116    /// Returns [`CacheError::Io`] on write failure or [`CacheError::Json`] if
117    /// `facts` cannot be encoded.
118    pub fn put(&self, blob_id: &str, facts: &FactSet) -> Result<(), CacheError> {
119        let path = self.path_for(blob_id);
120        if let Some(parent) = path.parent() {
121            fs::create_dir_all(parent)?;
122        }
123
124        // A temp name no concurrent writer can choose — see [`temp_path`] for why
125        // the counter is part of it and what happened before it was.
126        let tmp = temp_path(
127            &path,
128            std::process::id(),
129            std::time::SystemTime::now()
130                .duration_since(std::time::UNIX_EPOCH)
131                .unwrap_or_default()
132                .as_nanos(),
133            TEMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
134        );
135
136        let bytes = serde_json::to_vec(facts)?;
137        fs::write(&tmp, &bytes)?;
138
139        match fs::rename(&tmp, &path) {
140            Ok(()) => Ok(()),
141            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
142                match fs::remove_file(&path) {
143                    Ok(()) => {}
144                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
145                    Err(e) => return Err(e.into()),
146                }
147                fs::rename(&tmp, &path)?;
148                Ok(())
149            }
150            Err(e) => Err(e.into()),
151        }
152    }
153
154    /// Delete every entry whose key `retain` rejects, returning what the pass did.
155    ///
156    /// **This module deliberately does not know what a key means.** It derives
157    /// none and interprets none — the caller derives the key (see the module
158    /// doc), so the caller is the only thing entitled to say which keys are still
159    /// reachable. `retain` receives the *whole* key, reassembled from the shard
160    /// directory and the file stem, so a policy that reads any part of it reads
161    /// the same string [`Self::put`] was given.
162    ///
163    /// The pass is safe to run while other processes are using the same cache —
164    /// which is not optional, because the root lives under the **common** git dir
165    /// and every worktree shares it:
166    ///
167    /// - Entries are whole files written by atomic rename, and this deletes whole
168    ///   files, so no reader can observe a torn one. A reader that had already
169    ///   opened a deleted entry keeps reading it (POSIX); a reader that had not
170    ///   gets [`Self::get`]'s ordinary `None`, which is a cache miss — and a miss
171    ///   costs a re-extraction, never a wrong answer, because the cache is
172    ///   derived. That is the whole reason a mistaken `retain` is survivable.
173    /// - Nothing that is not an entry is touched, so a concurrent `put`'s temp
174    ///   file survives to be renamed.
175    /// - Shard directories are **not** removed, even when emptied. `put` does
176    ///   `create_dir_all` and *then* writes; removing the directory in between
177    ///   would fail an unrelated process's write to reclaim four kilobytes.
178    ///
179    /// # Errors
180    /// Returns [`CacheError::Io`] if the root or a shard cannot be listed — an
181    /// unreadable cache is reported, never silently swept as empty. Per-entry
182    /// delete failures are counted in [`ObjectSweep::failed`] instead, so one
183    /// stuck file does not abandon the rest.
184    pub fn sweep(&self, retain: &dyn Fn(&str) -> bool) -> Result<ObjectSweep, CacheError> {
185        let mut report = ObjectSweep::default();
186        for shard in fs::read_dir(&self.root)? {
187            let shard = shard?;
188            // `file_type` on a `DirEntry` does not follow links, so a symlinked
189            // directory is skipped rather than walked out of the cache.
190            if !shard.file_type()?.is_dir() {
191                report.skipped += 1;
192                continue;
193            }
194            let Some(prefix) = shard.file_name().to_str().map(str::to_owned) else {
195                // A shard name that is not UTF-8 cannot be half of a key this
196                // cache wrote, so its contents are not ours to judge.
197                report.skipped += 1;
198                continue;
199            };
200            Self::sweep_shard(&shard.path(), &prefix, retain, &mut report)?;
201        }
202        Ok(report)
203    }
204
205    /// One shard directory of [`Self::sweep`].
206    fn sweep_shard(
207        dir: &Path,
208        prefix: &str,
209        retain: &dyn Fn(&str) -> bool,
210        report: &mut ObjectSweep,
211    ) -> Result<(), CacheError> {
212        for entry in fs::read_dir(dir)? {
213            let entry = entry?;
214            let name = entry.file_name();
215            // An entry is named exactly `<rest>.json`. A temp file is
216            // `<rest>.json.tmp.<unique>` and so fails this test, which is the
217            // point: it belongs to a `put` that has not finished.
218            let Some(rest) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
219                report.skipped += 1;
220                continue;
221            };
222            // `symlink_metadata` does not follow links, so a symlink is never
223            // mistaken for an entry nor followed out of the cache — and it gives
224            // the size in the same call, with one race to handle instead of two.
225            let bytes = match fs::symlink_metadata(entry.path()) {
226                Ok(meta) if meta.is_file() => meta.len(),
227                Ok(_) => {
228                    report.skipped += 1;
229                    continue;
230                }
231                // Gone between listing and stat: another sweep of this shared
232                // cache got there first. Nothing left to reclaim, nothing wrong.
233                //
234                // **Defensive, and not covered by a test.** Hitting it needs a
235                // delete inside the window between `read_dir` yielding a name and
236                // this stat, which nothing here can open deterministically —
237                // whether a deleted name is still yielded depends on the
238                // platform's directory buffering, so a test for it would be
239                // flaky rather than a test. It is counted exactly as the same
240                // race on `remove_file` below is (`sweep_counts_an_entry_a_
241                // concurrent_sweep_removed_first`), which *is* covered; the
242                // alternative — letting it propagate — would make one sweep of a
243                // shared cache fail because another was doing its job.
244                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
245                    report.scanned += 1;
246                    report.raced += 1;
247                    continue;
248                }
249                Err(e) => return Err(e.into()),
250            };
251
252            report.scanned += 1;
253            // The key as `put` received it: the shard is its first two characters,
254            // not a hash of it, so concatenating recovers the original exactly.
255            let key = format!("{prefix}{rest}");
256            if retain(&key) {
257                report.retained += 1;
258                report.retained_bytes += bytes;
259                continue;
260            }
261            match fs::remove_file(entry.path()) {
262                Ok(()) => {
263                    report.removed += 1;
264                    report.freed_bytes += bytes;
265                }
266                Err(e) if e.kind() == std::io::ErrorKind::NotFound => report.raced += 1,
267                Err(_) => report.failed += 1,
268            }
269        }
270        Ok(())
271    }
272}
273
274/// The temp file [`ObjectCache::put`] writes before renaming it into place.
275///
276/// **Three components, and the third is not decoration.** `pid` separates
277/// processes; `now_nanos` separates calls within one — but only as finely as the
278/// platform clock, and `SystemTime::now()` is microsecond-granular on macOS and no
279/// better on some virtualised runners. Two threads of one process putting the
280/// *same* key inside one tick then chose the *same* temp path: the first rename
281/// consumed the file, and the second got `ENOENT` — a failed cache write surfacing
282/// as `CacheError::Io`, not as the miss a derived cache is allowed to have. `seq`
283/// is a process-wide counter, so that collision cannot occur at any clock
284/// resolution.
285///
286/// Found by #822, which made the corpus graph tests actually run on CI: three of
287/// them build graphs concurrently over a cold cache, and that is the first thing
288/// here to put one key from two threads at once. Measured before the counter:
289/// 6 failures in 160,000 concurrent same-key puts locally (~1 in 26,000), and one
290/// `no-default-features` job failure on Linux. After it: none in 480,000.
291///
292/// The name stays a sibling of the entry, so the rename is within one directory
293/// and therefore within one filesystem — which is what makes it atomic.
294fn temp_path(path: &Path, pid: u32, now_nanos: u128, seq: u64) -> PathBuf {
295    path.with_extension(format!("json.tmp.{pid}-{now_nanos}-{seq}"))
296}
297
298/// The `seq` above, one per process. `Relaxed` is enough: nothing is ordered
299/// against it — the only property required is that no two reads return the same
300/// value, which `fetch_add` gives at any ordering.
301static TEMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
302
303#[cfg(test)]
304mod tests {
305    use super::ObjectCache;
306    use crate::{Edge, EdgeKind, FactSet, Node, NodeKind};
307
308    /// A cache root unique to `name` and this process, with any stale copy gone.
309    fn fresh(name: &str) -> std::path::PathBuf {
310        let dir = std::env::temp_dir().join(format!("roteiro-cache-{name}-{}", std::process::id()));
311        std::fs::remove_dir_all(&dir).ok();
312        dir
313    }
314
315    fn sample() -> FactSet {
316        FactSet::new()
317            .with_node(Node::new("a", NodeKind::Fn, "a"))
318            .with_node(Node::new("b", NodeKind::Fn, "b"))
319            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
320    }
321
322    /// **Two puts of one key inside a single clock tick must not name one file.**
323    ///
324    /// The clock is pinned here rather than raced, because the defect this guards
325    /// is *rare* when raced — 6 failures in 160,000 concurrent puts is a test that
326    /// passes 26,000 times out of 26,001 and proves nothing on the run that
327    /// matters. Equal `now_nanos` is the condition the race produces, so asserting
328    /// on it directly is the same claim without the coin toss. Delete `seq` from
329    /// [`super::temp_path`] and this fails; nothing else in the suite does.
330    #[test]
331    fn two_puts_in_one_clock_tick_cannot_choose_one_temp_file() {
332        let entry = std::path::Path::new("/cache/de/adbeef.json");
333        let first = super::temp_path(entry, 7, 42, 0);
334        let second = super::temp_path(entry, 7, 42, 1);
335        assert_ne!(
336            first, second,
337            "same pid and same clock reading must still give different temp files, \
338             or one thread's rename consumes the other's and the loser gets ENOENT"
339        );
340        // A sibling of the entry, so the rename stays inside one directory and is
341        // therefore atomic. A temp file elsewhere would cross a filesystem and
342        // stop being a rename at all.
343        assert_eq!(first.parent(), entry.parent(), "{first:?}");
344    }
345
346    /// **And the counter must be wired into `put`, not merely available to it.**
347    ///
348    /// The test above holds [`super::temp_path`]'s own property, which a future
349    /// edit could satisfy while passing a constant at the call site — or while
350    /// restoring `{pid}-{nanos}` naming there — and the race would be back with
351    /// both tests green. This one reads the names `put` actually writes.
352    ///
353    /// Deterministic, and deliberately not a concurrency test: the entry path is
354    /// pre-occupied by a **directory**, so each `put`'s rename fails and leaves its
355    /// temp file behind to be inspected. Two puts of one key must leave two files
356    /// whose suffix has three fields and whose *third* field differs — the shape
357    /// catches a reverted name, and the difference catches a constant. Racing two
358    /// threads instead would catch both only about one run in 26,000.
359    #[test]
360    fn put_draws_a_fresh_sequence_value_for_each_temp_file() {
361        let dir = fresh("seq-wiring");
362        let cache = ObjectCache::open(&dir).expect("open");
363        let shard = dir.join("de");
364        std::fs::create_dir_all(shard.join("adbeef.json")).expect("occupy the entry path");
365
366        // The rename cannot replace a directory, so each put fails *after* writing
367        // its temp file. That failure is the instrument, not the subject.
368        assert!(
369            cache.put("deadbeef", &sample()).is_err(),
370            "a put whose destination is a directory must fail, or there is no temp \
371             file left to read"
372        );
373        assert!(cache.put("deadbeef", &sample()).is_err());
374
375        let suffixes: Vec<String> = std::fs::read_dir(&shard)
376            .expect("read the shard")
377            .filter_map(|entry| {
378                let name = entry.ok()?.file_name().to_string_lossy().into_owned();
379                let (_, suffix) = name.split_once(".json.tmp.")?;
380                Some(suffix.to_owned())
381            })
382            .collect();
383        assert_eq!(suffixes.len(), 2, "two puts, two temp files: {suffixes:?}");
384
385        let mut seqs = Vec::new();
386        for suffix in &suffixes {
387            let fields: Vec<&str> = suffix.split('-').collect();
388            assert_eq!(
389                fields.len(),
390                3,
391                "a temp name is `<pid>-<nanos>-<seq>`; {suffix:?} has no sequence \
392                 field, so two threads can choose one name again"
393            );
394            seqs.push(fields[2].to_owned());
395        }
396        assert_ne!(
397            seqs[0], seqs[1],
398            "both puts used sequence {:?}, so the call site is passing a constant \
399             rather than drawing from the counter",
400            seqs[0]
401        );
402
403        std::fs::remove_dir_all(&dir).ok();
404    }
405
406    #[test]
407    fn put_get_round_trip_and_miss() {
408        let dir = std::env::temp_dir().join(format!("roteiro-cache-{}", std::process::id()));
409        std::fs::remove_dir_all(&dir).ok();
410        let cache = ObjectCache::open(&dir).expect("open");
411
412        assert!(!cache.contains("deadbeef"));
413        assert!(cache.get("deadbeef").expect("get").is_none());
414
415        let facts = sample();
416        cache.put("deadbeef", &facts).expect("put");
417        assert!(cache.contains("deadbeef"));
418        assert_eq!(cache.get("deadbeef").expect("get"), Some(facts));
419
420        std::fs::remove_dir_all(&dir).expect("cleanup");
421    }
422
423    #[test]
424    fn put_overwrites_existing_entry() {
425        let dir =
426            std::env::temp_dir().join(format!("roteiro-cache-overwrite-{}", std::process::id()));
427        std::fs::remove_dir_all(&dir).ok();
428        let cache = ObjectCache::open(&dir).expect("open");
429
430        cache.put("beef", &sample()).expect("first put");
431        // A second put for the same key must atomically replace the entry.
432        let replacement = FactSet::new().with_node(Node::new("only", NodeKind::File, "only"));
433        cache.put("beef", &replacement).expect("overwrite");
434        assert_eq!(cache.get("beef").expect("get"), Some(replacement));
435
436        std::fs::remove_dir_all(&dir).expect("cleanup");
437    }
438
439    /// The predicate decides, and it decides on the **whole** key — not the file
440    /// stem, which is the key minus its shard. Keys differing only in their first
441    /// character land in different shard directories with *identical* stems, so a
442    /// sweep that judged the stem would give both the same verdict. Here one is
443    /// kept and one removed, which only the reassembled key can distinguish.
444    #[test]
445    fn sweep_judges_the_whole_key_not_the_file_stem() {
446        let dir = fresh("sweep-key");
447        let cache = ObjectCache::open(&dir).expect("open");
448        cache.put("aakeep", &sample()).expect("put keep");
449        cache.put("bakeep", &sample()).expect("put drop");
450
451        let swept = cache
452            .sweep(&|key| key.starts_with("aa"))
453            .expect("sweep should read the cache");
454
455        assert_eq!(swept.scanned, 2);
456        assert_eq!(swept.retained, 1, "exactly one key starts with `aa`");
457        assert_eq!(swept.removed, 1);
458        assert!(cache.contains("aakeep"), "the retained entry must survive");
459        assert!(!cache.contains("bakeep"), "the rejected entry must be gone");
460        std::fs::remove_dir_all(&dir).expect("cleanup");
461    }
462
463    /// Bytes are accounted for on both sides. A sweep reporting only what it
464    /// freed cannot be told apart from one that had nothing to free.
465    #[test]
466    fn sweep_accounts_for_both_freed_and_retained_bytes() {
467        let dir = fresh("sweep-bytes");
468        let cache = ObjectCache::open(&dir).expect("open");
469        cache.put("keeper", &sample()).expect("put");
470        cache.put("goner", &sample()).expect("put");
471        let each = std::fs::metadata(dir.join("ke").join("eper.json"))
472            .expect("stat")
473            .len();
474        assert!(each > 0, "an entry with facts in it is not empty");
475
476        let swept = cache.sweep(&|key| key == "keeper").expect("sweep");
477
478        assert_eq!(swept.freed_bytes, each, "the removed entry's size, exactly");
479        assert_eq!(swept.retained_bytes, each);
480        assert_eq!((swept.raced, swept.failed, swept.skipped), (0, 0, 0));
481        std::fs::remove_dir_all(&dir).expect("cleanup");
482    }
483
484    /// A `put` in flight has written its temp file but not yet renamed it. The
485    /// sweep must not see it as an entry, and must not delete it — the rename
486    /// that follows would fail and take an unrelated sync down with it. The
487    /// predicate is `|_| false`, so *everything* it is shown is deleted: only
488    /// never being shown the temp file can save it.
489    #[test]
490    fn sweep_never_touches_a_put_in_flight() {
491        let dir = fresh("sweep-tmp");
492        let cache = ObjectCache::open(&dir).expect("open");
493        cache.put("deadbeef", &sample()).expect("put");
494        let tmp = dir.join("de").join("adbeef.json.tmp.4242-1");
495        std::fs::write(&tmp, b"half-written").expect("stage a temp file");
496
497        let swept = cache.sweep(&|_| false).expect("sweep");
498
499        assert_eq!(swept.scanned, 1, "the temp file is not a cache entry");
500        assert_eq!(swept.removed, 1);
501        assert_eq!(swept.skipped, 1, "and it is reported, not silently ignored");
502        assert!(tmp.exists(), "a `put` in flight must survive the sweep");
503        std::fs::remove_dir_all(&dir).expect("cleanup");
504    }
505
506    /// Emptying a shard must not remove the shard directory. `put` does
507    /// `create_dir_all` and *then* writes into it; a sweep that removed the
508    /// directory in between would fail that write to reclaim an empty inode.
509    #[test]
510    fn sweep_leaves_emptied_shard_directories_in_place() {
511        let dir = fresh("sweep-shard");
512        let cache = ObjectCache::open(&dir).expect("open");
513        cache.put("deadbeef", &sample()).expect("put");
514        let shard = dir.join("de");
515
516        let swept = cache.sweep(&|_| false).expect("sweep");
517
518        assert_eq!(swept.removed, 1);
519        assert!(shard.is_dir(), "the shard directory stays");
520        // And the cache is still usable through it, which is the point.
521        cache.put("deadbeef", &sample()).expect("put after sweep");
522        assert!(cache.contains("deadbeef"));
523        std::fs::remove_dir_all(&dir).expect("cleanup");
524    }
525
526    /// A second sweep of the same shared cache that gets to an entry first is not
527    /// a fault: the file is gone, which is what this pass wanted. It is counted
528    /// as `raced` rather than `removed`, because this pass freed none of those
529    /// bytes and reporting them would double-count the reclaim across the two.
530    ///
531    /// The race is made deterministic by having `retain` delete the entry itself
532    /// before rejecting it — exactly the window a concurrent sweep opens.
533    #[test]
534    fn sweep_counts_an_entry_a_concurrent_sweep_removed_first() {
535        let dir = fresh("sweep-race");
536        let cache = ObjectCache::open(&dir).expect("open");
537        cache.put("deadbeef", &sample()).expect("put");
538
539        let root = dir.clone();
540        let swept = cache
541            .sweep(&move |_| {
542                std::fs::remove_file(root.join("de").join("adbeef.json")).expect("the other sweep");
543                false
544            })
545            .expect("sweep");
546
547        assert_eq!((swept.scanned, swept.raced), (1, 1), "{swept:?}");
548        assert_eq!(
549            (swept.removed, swept.freed_bytes),
550            (0, 0),
551            "this pass freed none of those bytes: {swept:?}",
552        );
553        std::fs::remove_dir_all(&dir).expect("cleanup");
554    }
555
556    /// A *directory* named `<something>.json` is not an entry, whatever it looks
557    /// like. It is skipped rather than judged — the predicate is never shown a
558    /// key that no `put` ever wrote.
559    #[test]
560    fn sweep_skips_a_directory_wearing_an_entry_name() {
561        let dir = fresh("sweep-dir");
562        let cache = ObjectCache::open(&dir).expect("open");
563        cache.put("deadbeef", &sample()).expect("put");
564        let impostor = dir.join("de").join("cafe.json");
565        std::fs::create_dir_all(&impostor).expect("create the impostor");
566
567        let swept = cache.sweep(&|_| false).expect("sweep");
568
569        assert_eq!(swept.scanned, 1, "only the real entry: {swept:?}");
570        assert_eq!(swept.skipped, 1, "{swept:?}");
571        assert!(impostor.is_dir(), "the directory must be left alone");
572        std::fs::remove_dir_all(&dir).expect("cleanup");
573    }
574
575    /// An unreadable cache is an error, never a silent "nothing to sweep". A
576    /// sweep that swallowed the failure would report `removed: 0` — the same
577    /// output a healthy, already-clean cache produces.
578    #[test]
579    fn sweep_of_a_missing_root_is_an_error_not_an_empty_pass() {
580        let dir = fresh("sweep-missing");
581        let cache = ObjectCache::open(&dir).expect("open");
582        std::fs::remove_dir_all(&dir).expect("remove the root out from under it");
583
584        let err = cache
585            .sweep(&|_| true)
586            .expect_err("a root that cannot be listed must be reported");
587        assert!(matches!(err, super::CacheError::Io(_)), "got {err:?}");
588    }
589
590    #[test]
591    fn short_ids_do_not_panic_on_shard() {
592        let dir = std::env::temp_dir().join(format!("roteiro-cache-short-{}", std::process::id()));
593        std::fs::remove_dir_all(&dir).ok();
594        let cache = ObjectCache::open(&dir).expect("open");
595        cache.put("a", &FactSet::new()).expect("put short id");
596        assert_eq!(cache.get("a").expect("get"), Some(FactSet::new()));
597        std::fs::remove_dir_all(&dir).expect("cleanup");
598    }
599}