Skip to main content

rustial_engine/io/
disk_cache.rs

1//! Optional on-disk tile cache using a flat-file directory layout.
2//!
3//! Stores tiles as individual files under `{base_dir}/{z}/{x}/{y}.bin`.
4//! Feature-gated behind the `disk-cache` feature flag.
5//!
6//! # On-disk format
7//!
8//! Each file is a minimal binary envelope:
9//!
10//! ```text
11//! [u8 magic 'R']['M'][u8 version][u8 kind][u32 width LE][u32 height LE][RGBA8 data...]
12//! ```
13//!
14//! - **Magic** (`RM`) guards against reading arbitrary files.
15//! - **Version** (`1`) allows future format changes without silent corruption.
16//! - **Kind** (`0` = Raster RGBA8) supports future tile data variants.
17//! - Width / height / pixel data are identical to [`DecodedImage`](crate::tile_source::DecodedImage).
18//!
19//! # Thread safety
20//!
21//! All methods use only local `std::fs` calls with no interior mutability,
22//! so `&DiskCache` is safe to share across threads. File-system atomicity
23//! is achieved by writing to a temporary file and renaming, which is
24//! atomic on all major platforms.
25//!
26//! # Eviction
27//!
28//! This cache is **write-only** -- it does not enforce a maximum size.
29//! Call [`evict_older_than`](DiskCache::evict_older_than) periodically
30//! (e.g. at startup) to prune stale entries, or use
31//! [`clear`](DiskCache::clear) to wipe everything.
32
33use crate::tile_source::TileData;
34use rustial_math::TileId;
35use std::io;
36use std::path::{Path, PathBuf};
37use std::time::SystemTime;
38use thiserror::Error;
39
40// ---------------------------------------------------------------------------
41// Binary format constants
42// ---------------------------------------------------------------------------
43
44/// Two-byte magic header identifying a rustial cache file.
45const MAGIC: [u8; 2] = [b'R', b'M'];
46
47/// Current format version.  Bump when the on-disk layout changes.
48const VERSION: u8 = 1;
49
50/// Kind tag for [`TileData::Raster`].
51const KIND_RASTER: u8 = 0;
52
53/// Total header size: magic (2) + version (1) + kind (1) + width (4) + height (4).
54const HEADER_LEN: usize = 12;
55
56// ---------------------------------------------------------------------------
57// Error type
58// ---------------------------------------------------------------------------
59
60/// Errors from disk cache operations.
61#[derive(Debug, Error)]
62pub enum DiskCacheError {
63    /// An I/O error occurred reading or writing a cache file.
64    #[error("disk cache I/O error: {0}")]
65    Io(#[from] io::Error),
66    /// The cached data could not be decoded (corrupt file, wrong version, etc.).
67    #[error("disk cache decode error: {0}")]
68    Decode(String),
69}
70
71// ---------------------------------------------------------------------------
72// Internal helper types
73// ---------------------------------------------------------------------------
74
75/// Metadata for a single cache file, used by [`DiskCache::evict_to_size`].
76struct CacheFileInfo {
77    path: PathBuf,
78    size: u64,
79    modified: SystemTime,
80}
81
82// ---------------------------------------------------------------------------
83// DiskCache
84// ---------------------------------------------------------------------------
85
86/// A flat-file on-disk tile cache.
87///
88/// Directory layout: `{base_dir}/{z}/{x}/{y}.bin`.
89///
90/// # Example
91///
92/// ```rust,no_run
93/// use rustial_engine::DiskCache;
94/// use rustial_engine::TileId;
95///
96/// let cache = DiskCache::new("./tile_cache").unwrap();
97/// let tile = TileId::new(10, 512, 340);
98/// if let Some(data) = cache.get(&tile).unwrap() {
99///     // use cached tile
100/// }
101/// ```
102#[derive(Debug)]
103pub struct DiskCache {
104    base_dir: PathBuf,
105}
106
107impl DiskCache {
108    /// Create (or open) a disk cache rooted at `base_dir`.
109    ///
110    /// The directory tree is created on demand; this call only ensures
111    /// the root directory exists.
112    pub fn new(base_dir: impl Into<PathBuf>) -> Result<Self, DiskCacheError> {
113        let base_dir = base_dir.into();
114        std::fs::create_dir_all(&base_dir)?;
115        Ok(Self { base_dir })
116    }
117
118    /// Load a tile from cache.
119    ///
120    /// Returns `Ok(None)` if the tile is not cached.
121    pub fn get(&self, id: &TileId) -> Result<Option<TileData>, DiskCacheError> {
122        let path = self.tile_path(id);
123        match std::fs::read(&path) {
124            Ok(bytes) => decode_tile_data(&bytes).map(Some),
125            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
126            Err(e) => Err(DiskCacheError::Io(e)),
127        }
128    }
129
130    /// Store a tile in the cache.
131    ///
132    /// Writes to a temporary file first, then renames atomically to
133    /// prevent readers from seeing a partial write.
134    pub fn put(&self, id: &TileId, data: &TileData) -> Result<(), DiskCacheError> {
135        let bytes = encode_tile_data(data);
136        if bytes.is_empty() {
137            // Encoding not supported for this tile variant (e.g. Vector).
138            return Err(DiskCacheError::Decode(
139                "disk-cache serialisation not supported for this tile data kind".into(),
140            ));
141        }
142
143        let path = self.tile_path(id);
144        if let Some(parent) = path.parent() {
145            std::fs::create_dir_all(parent)?;
146        }
147
148        // Write to a sibling temp file, then rename.
149        let tmp = path.with_extension("tmp");
150        std::fs::write(&tmp, bytes)?;
151        std::fs::rename(&tmp, &path)?;
152        Ok(())
153    }
154
155    /// Check whether a tile is present in the cache.
156    pub fn contains(&self, id: &TileId) -> bool {
157        self.tile_path(id).exists()
158    }
159
160    /// Remove a single tile from the cache.
161    ///
162    /// Returns `true` if the file was deleted, `false` if it was not cached.
163    pub fn remove(&self, id: &TileId) -> Result<bool, DiskCacheError> {
164        let path = self.tile_path(id);
165        match std::fs::remove_file(&path) {
166            Ok(()) => Ok(true),
167            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
168            Err(e) => Err(DiskCacheError::Io(e)),
169        }
170    }
171
172    /// Delete **all** cached tiles.
173    ///
174    /// Removes the entire directory tree under [`base_dir`](Self::base_dir)
175    /// and recreates the root.
176    pub fn clear(&self) -> Result<(), DiskCacheError> {
177        if self.base_dir.exists() {
178            std::fs::remove_dir_all(&self.base_dir)?;
179        }
180        std::fs::create_dir_all(&self.base_dir)?;
181        Ok(())
182    }
183
184    /// Remove cached tiles whose last-modified time is older than `max_age`.
185    ///
186    /// Walks the directory tree, deletes matching `.bin` files, and
187    /// removes any empty parent directories left behind.
188    ///
189    /// Returns the number of files deleted.
190    pub fn evict_older_than(&self, max_age: std::time::Duration) -> Result<usize, DiskCacheError> {
191        let cutoff = SystemTime::now()
192            .checked_sub(max_age)
193            .unwrap_or(SystemTime::UNIX_EPOCH);
194
195        let mut removed = 0usize;
196        self.walk_and_evict(&self.base_dir, cutoff, &mut removed)?;
197        Ok(removed)
198    }
199
200    /// Remove cached tiles until the total on-disk size is at or below
201    /// `max_bytes`.
202    ///
203    /// Files are sorted oldest-first (by last-modified time) and deleted
204    /// in that order until the total size drops below the limit.
205    ///
206    /// Returns the number of files deleted.
207    pub fn evict_to_size(&self, max_bytes: u64) -> Result<usize, DiskCacheError> {
208        let mut entries = Vec::new();
209        self.walk_file_info(&self.base_dir, &mut entries)?;
210
211        // Sort oldest-first (smallest modified time first).
212        entries.sort_by_key(|e| e.modified);
213
214        let total: u64 = entries.iter().map(|e| e.size).sum();
215        if total <= max_bytes {
216            return Ok(0);
217        }
218
219        let mut current = total;
220        let mut removed = 0usize;
221        for entry in &entries {
222            if current <= max_bytes {
223                break;
224            }
225            if std::fs::remove_file(&entry.path).is_ok() {
226                current = current.saturating_sub(entry.size);
227                removed += 1;
228                // Try to remove empty parent directories.
229                if let Some(parent) = entry.path.parent() {
230                    let _ = std::fs::remove_dir(parent);
231                }
232            }
233        }
234        Ok(removed)
235    }
236
237    /// Count the number of cached tiles (walks the directory tree).
238    pub fn len(&self) -> Result<usize, DiskCacheError> {
239        let mut count = 0usize;
240        self.walk_count(&self.base_dir, &mut count)?;
241        Ok(count)
242    }
243
244    /// Whether the cache directory is empty (no `.bin` files).
245    pub fn is_empty(&self) -> Result<bool, DiskCacheError> {
246        Ok(self.len()? == 0)
247    }
248
249    /// The root directory of this cache.
250    pub fn base_dir(&self) -> &Path {
251        &self.base_dir
252    }
253
254    // -- private helpers --------------------------------------------------
255
256    fn tile_path(&self, id: &TileId) -> PathBuf {
257        self.base_dir
258            .join(id.zoom.to_string())
259            .join(id.x.to_string())
260            .join(format!("{}.bin", id.y))
261    }
262
263    fn walk_and_evict(
264        &self,
265        dir: &Path,
266        cutoff: SystemTime,
267        removed: &mut usize,
268    ) -> Result<(), DiskCacheError> {
269        let entries = match std::fs::read_dir(dir) {
270            Ok(e) => e,
271            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
272            Err(e) => return Err(DiskCacheError::Io(e)),
273        };
274
275        for entry in entries {
276            let entry = entry?;
277            let ft = entry.file_type()?;
278
279            if ft.is_dir() {
280                self.walk_and_evict(&entry.path(), cutoff, removed)?;
281                // Remove the directory if it is now empty.
282                let _ = std::fs::remove_dir(entry.path());
283            } else if ft.is_file() {
284                if let Some(ext) = entry.path().extension() {
285                    if ext == "bin" {
286                        let modified = entry
287                            .metadata()
288                            .and_then(|m| m.modified())
289                            .unwrap_or(SystemTime::UNIX_EPOCH);
290                        if modified < cutoff {
291                            std::fs::remove_file(entry.path())?;
292                            *removed += 1;
293                        }
294                    }
295                }
296            }
297        }
298        Ok(())
299    }
300
301    fn walk_file_info(
302        &self,
303        dir: &Path,
304        out: &mut Vec<CacheFileInfo>,
305    ) -> Result<(), DiskCacheError> {
306        let entries = match std::fs::read_dir(dir) {
307            Ok(e) => e,
308            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
309            Err(e) => return Err(DiskCacheError::Io(e)),
310        };
311
312        for entry in entries {
313            let entry = entry?;
314            let ft = entry.file_type()?;
315            if ft.is_dir() {
316                self.walk_file_info(&entry.path(), out)?;
317            } else if ft.is_file() {
318                if let Some(ext) = entry.path().extension() {
319                    if ext == "bin" {
320                        let meta = entry.metadata()?;
321                        out.push(CacheFileInfo {
322                            path: entry.path(),
323                            size: meta.len(),
324                            modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
325                        });
326                    }
327                }
328            }
329        }
330        Ok(())
331    }
332
333    fn walk_count(&self, dir: &Path, count: &mut usize) -> Result<(), DiskCacheError> {
334        let entries = match std::fs::read_dir(dir) {
335            Ok(e) => e,
336            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
337            Err(e) => return Err(DiskCacheError::Io(e)),
338        };
339
340        for entry in entries {
341            let entry = entry?;
342            let ft = entry.file_type()?;
343            if ft.is_dir() {
344                self.walk_count(&entry.path(), count)?;
345            } else if ft.is_file() {
346                if let Some(ext) = entry.path().extension() {
347                    if ext == "bin" {
348                        *count += 1;
349                    }
350                }
351            }
352        }
353        Ok(())
354    }
355}
356
357// ---------------------------------------------------------------------------
358// Encode / Decode
359// ---------------------------------------------------------------------------
360
361fn encode_tile_data(data: &TileData) -> Vec<u8> {
362    match data {
363        TileData::Raster(img) => {
364            let mut buf = Vec::with_capacity(HEADER_LEN + img.data.len());
365            buf.extend_from_slice(&MAGIC);
366            buf.push(VERSION);
367            buf.push(KIND_RASTER);
368            buf.extend_from_slice(&img.width.to_le_bytes());
369            buf.extend_from_slice(&img.height.to_le_bytes());
370            buf.extend_from_slice(&img.data);
371            buf
372        }
373        TileData::Vector(_) | TileData::RawVector(_) => {
374            // Vector tile disk-cache serialisation is not yet implemented.
375            // Return a zero-length buffer; callers should check before writing.
376            Vec::new()
377        }
378    }
379}
380
381fn decode_tile_data(bytes: &[u8]) -> Result<TileData, DiskCacheError> {
382    if bytes.len() < HEADER_LEN {
383        return Err(DiskCacheError::Decode(format![
384            "file too short ({} bytes, need at least {HEADER_LEN})",
385            bytes.len()
386        ]));
387    }
388
389    // Validate magic bytes.
390    if bytes[0..2] != MAGIC {
391        return Err(DiskCacheError::Decode(format![
392            "invalid magic: expected {:?}, got {:?}",
393            MAGIC,
394            &bytes[0..2]
395        ]));
396    }
397
398    // Validate version.
399    let version = bytes[2];
400    if version != VERSION {
401        return Err(DiskCacheError::Decode(format![
402            "unsupported version {version} (expected {VERSION})"
403        ]));
404    }
405
406    let kind = bytes[3];
407    match kind {
408        KIND_RASTER => {
409            let width = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
410            let height = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
411            let pixel_data = &bytes[HEADER_LEN..];
412
413            let expected_len = (width as usize)
414                .checked_mul(height as usize)
415                .and_then(|n| n.checked_mul(4))
416                .ok_or_else(|| {
417                    DiskCacheError::Decode(format!["dimensions overflow: {width}x{height}"])
418                })?;
419
420            if pixel_data.len() != expected_len {
421                return Err(DiskCacheError::Decode(format![
422                    "expected {expected_len} bytes of pixel data for {width}x{height}, got {}",
423                    pixel_data.len()
424                ]));
425            }
426
427            Ok(TileData::Raster(crate::tile_source::DecodedImage {
428                width,
429                height,
430                data: std::sync::Arc::new(pixel_data.to_vec()),
431            }))
432        }
433        _ => Err(DiskCacheError::Decode(format![
434            "unknown tile kind tag: {kind}"
435        ])),
436    }
437}
438
439// ---------------------------------------------------------------------------
440// Tests
441// ---------------------------------------------------------------------------
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::tile_source::DecodedImage;
447
448    /// Create a unique temp directory for each test.
449    fn temp_cache(name: &str) -> (DiskCache, PathBuf) {
450        let dir = std::env::temp_dir()
451            .join("rustial_disk_cache_test")
452            .join(name);
453        let _ = std::fs::remove_dir_all(&dir);
454        let cache = DiskCache::new(&dir).expect("create cache");
455        (cache, dir)
456    }
457
458    fn sample_tile() -> TileData {
459        TileData::Raster(DecodedImage {
460            width: 2,
461            height: 2,
462            data: vec![255u8; 16].into(), // 2x2 RGBA
463        })
464    }
465
466    // -- roundtrip ---------------------------------------------------------
467
468    #[test]
469    fn roundtrip_raster_tile() {
470        let (cache, dir) = temp_cache("roundtrip");
471        let id = TileId::new(5, 10, 15);
472        let data = sample_tile();
473
474        assert!(!cache.contains(&id));
475        cache.put(&id, &data).expect("put");
476        assert!(cache.contains(&id));
477
478        match cache.get(&id).expect("get").expect("some") {
479            TileData::Raster(img) => {
480                assert_eq!(img.width, 2);
481                assert_eq!(img.height, 2);
482                assert_eq!(img.data.len(), 16);
483                assert!(img.data.iter().all(|&b| b == 255));
484            }
485            TileData::Vector(_) | TileData::RawVector(_) => panic!("expected Raster, got Vector"),
486        }
487
488        assert!(cache.remove(&id).expect("remove"));
489        assert!(!cache.contains(&id));
490        let _ = std::fs::remove_dir_all(&dir);
491    }
492
493    // -- miss returns None -------------------------------------------------
494
495    #[test]
496    fn get_nonexistent_returns_none() {
497        let (cache, dir) = temp_cache("miss");
498        assert!(cache.get(&TileId::new(0, 0, 0)).expect("get").is_none());
499        let _ = std::fs::remove_dir_all(&dir);
500    }
501
502    // -- remove nonexistent returns false ----------------------------------
503
504    #[test]
505    fn remove_nonexistent_returns_false() {
506        let (cache, dir) = temp_cache("remove_miss");
507        assert!(!cache.remove(&TileId::new(0, 0, 0)).expect("remove"));
508        let _ = std::fs::remove_dir_all(&dir);
509    }
510
511    // -- decode validation -------------------------------------------------
512
513    #[test]
514    fn decode_too_short() {
515        assert!(decode_tile_data(&[0, 1, 2]).is_err());
516    }
517
518    #[test]
519    fn decode_bad_magic() {
520        let mut data = vec![b'X', b'Y', VERSION, KIND_RASTER];
521        data.extend_from_slice(&1u32.to_le_bytes()); // width
522        data.extend_from_slice(&1u32.to_le_bytes()); // height
523        data.extend_from_slice(&[0u8; 4]); // 1x1 RGBA
524        assert!(decode_tile_data(&data).is_err());
525    }
526
527    #[test]
528    fn decode_wrong_version() {
529        let mut data = vec![b'R', b'M', 99, KIND_RASTER];
530        data.extend_from_slice(&1u32.to_le_bytes());
531        data.extend_from_slice(&1u32.to_le_bytes());
532        data.extend_from_slice(&[0u8; 4]);
533        assert!(decode_tile_data(&data).is_err());
534    }
535
536    #[test]
537    fn decode_unknown_kind() {
538        let mut data = vec![b'R', b'M', VERSION, 77];
539        data.extend_from_slice(&1u32.to_le_bytes());
540        data.extend_from_slice(&1u32.to_le_bytes());
541        data.extend_from_slice(&[0u8; 4]);
542        assert!(decode_tile_data(&data).is_err());
543    }
544
545    #[test]
546    fn decode_wrong_pixel_length() {
547        let mut data = vec![b'R', b'M', VERSION, KIND_RASTER];
548        data.extend_from_slice(&2u32.to_le_bytes()); // width=2
549        data.extend_from_slice(&2u32.to_le_bytes()); // height=2
550        data.extend_from_slice(&[0u8; 4]); // only 4 bytes, need 16
551        assert!(decode_tile_data(&data).is_err());
552    }
553
554    #[test]
555    fn decode_overflow_dimensions() {
556        let mut data = vec![b'R', b'M', VERSION, KIND_RASTER];
557        data.extend_from_slice(&u32::MAX.to_le_bytes()); // width
558        data.extend_from_slice(&u32::MAX.to_le_bytes()); // height
559                                                         // No pixel data -- the overflow check fires first.
560        assert!(decode_tile_data(&data).is_err());
561    }
562
563    // -- clear -------------------------------------------------------------
564
565    #[test]
566    fn clear_removes_all_tiles() {
567        let (cache, dir) = temp_cache("clear");
568        let data = sample_tile();
569        cache.put(&TileId::new(1, 0, 0), &data).expect("put");
570        cache.put(&TileId::new(2, 1, 1), &data).expect("put");
571        assert_eq!(cache.len().expect("len"), 2);
572
573        cache.clear().expect("clear");
574        assert!(cache.is_empty().expect("is_empty"));
575        let _ = std::fs::remove_dir_all(&dir);
576    }
577
578    // -- len / is_empty ----------------------------------------------------
579
580    #[test]
581    fn len_counts_tiles() {
582        let (cache, dir) = temp_cache("len");
583        assert!(cache.is_empty().expect("empty"));
584
585        let data = sample_tile();
586        cache.put(&TileId::new(0, 0, 0), &data).expect("put");
587        cache.put(&TileId::new(1, 0, 0), &data).expect("put");
588        assert_eq!(cache.len().expect("len"), 2);
589        let _ = std::fs::remove_dir_all(&dir);
590    }
591
592    // -- eviction ----------------------------------------------------------
593
594    #[test]
595    fn evict_removes_old_tiles() {
596        let (cache, dir) = temp_cache("evict");
597        let data = sample_tile();
598        cache.put(&TileId::new(0, 0, 0), &data).expect("put");
599
600        // Evict tiles older than 0 seconds (i.e. everything).
601        let removed = cache
602            .evict_older_than(std::time::Duration::ZERO)
603            .expect("evict");
604        assert_eq!(removed, 1);
605        assert!(cache.is_empty().expect("is_empty"));
606        let _ = std::fs::remove_dir_all(&dir);
607    }
608
609    #[test]
610    fn evict_keeps_recent_tiles() {
611        let (cache, dir) = temp_cache("evict_keep");
612        let data = sample_tile();
613        cache.put(&TileId::new(0, 0, 0), &data).expect("put");
614
615        // Evict tiles older than 1 hour -- the tile we just wrote stays.
616        let removed = cache
617            .evict_older_than(std::time::Duration::from_secs(3600))
618            .expect("evict");
619        assert_eq!(removed, 0);
620        assert_eq!(cache.len().expect("len"), 1);
621        let _ = std::fs::remove_dir_all(&dir);
622    }
623
624    #[test]
625    fn evict_to_size_removes_excess_files() {
626        let (cache, dir) = temp_cache("evict_to_size");
627        let data = sample_tile();
628        cache.put(&TileId::new(0, 0, 0), &data).expect("put");
629        cache.put(&TileId::new(1, 0, 0), &data).expect("put");
630        cache.put(&TileId::new(2, 0, 0), &data).expect("put");
631        assert_eq!(cache.len().expect("len"), 3);
632
633        // Each sample_tile is 2×2 RGBA = 16 bytes pixel data + 12 byte header
634        // = 28 bytes on disk.  Three files = 84 bytes total.
635
636        // Evict to a size limit above the current total -- no files removed.
637        let removed = cache.evict_to_size(100).expect("evict_to_size");
638        assert_eq!(removed, 0);
639        assert_eq!(cache.len().expect("len"), 3);
640
641        // Evict to a size that requires removing exactly one file (84 − 28 = 56 ≤ 60).
642        let removed = cache.evict_to_size(60).expect("evict_to_size");
643        assert_eq!(removed, 1);
644        assert_eq!(cache.len().expect("len"), 2);
645
646        // Clean up the remaining tiles.
647        let _ = std::fs::remove_dir_all(&dir);
648    }
649
650    #[test]
651    fn evict_to_size_removes_oldest() {
652        let (cache, dir) = temp_cache("evict_size");
653        let data = sample_tile();
654
655        // Each sample_tile is 2x2 RGBA = 16 bytes of pixel data + 12 byte header = 28 bytes on disk.
656        cache.put(&TileId::new(0, 0, 0), &data).expect("put");
657        // Brief sleep so modified times differ.
658        std::thread::sleep(std::time::Duration::from_millis(50));
659        cache.put(&TileId::new(1, 0, 0), &data).expect("put");
660
661        assert_eq!(cache.len().expect("len"), 2);
662
663        // Evict to a size that fits only one file.
664        let removed = cache.evict_to_size(30).expect("evict");
665        assert_eq!(removed, 1);
666        assert_eq!(cache.len().expect("len"), 1);
667        // The older tile (zoom 0) should have been evicted.
668        assert!(!cache.contains(&TileId::new(0, 0, 0)));
669        assert!(cache.contains(&TileId::new(1, 0, 0)));
670
671        let _ = std::fs::remove_dir_all(&dir);
672    }
673
674    #[test]
675    fn evict_to_size_noop_when_under_limit() {
676        let (cache, dir) = temp_cache("evict_size_noop");
677        let data = sample_tile();
678        cache.put(&TileId::new(0, 0, 0), &data).expect("put");
679
680        let removed = cache.evict_to_size(1_000_000).expect("evict");
681        assert_eq!(removed, 0);
682        assert_eq!(cache.len().expect("len"), 1);
683        let _ = std::fs::remove_dir_all(&dir);
684    }
685}