Skip to main content

simple_gal/
cache.rs

1//! Image processing cache for incremental builds.
2//!
3//! AVIF encoding is the bottleneck of the build pipeline — a single image
4//! at three responsive sizes can take several seconds through rav1e. This
5//! module lets the process stage skip encoding when the source image and
6//! encoding parameters haven't changed since the last build.
7//!
8//! # Design
9//!
10//! The cache targets only the expensive encoding operations
11//! ([`create_responsive_images`](crate::imaging::create_responsive_images) and
12//! [`create_thumbnail`](crate::imaging::create_thumbnail)). Everything else
13//! — dimension reads, IPTC metadata extraction, title/description resolution —
14//! always runs. This means metadata changes (e.g. updating an IPTC title in
15//! Lightroom) are picked up immediately without a cache bust.
16//!
17//! ## Cache keys
18//!
19//! The cache is **content-addressed**: lookups are by the combination of
20//! `source_hash` and `params_hash`, not by output file path. This means
21//! album renames, file renumbers, and slug changes do not invalidate the
22//! cache — only actual image content or encoding parameter changes do.
23//!
24//! - **`source_hash`**: SHA-256 of the source file contents. Content-based
25//!   rather than mtime-based so it survives `git checkout` (which resets
26//!   modification times). Computed once per source file and shared across all
27//!   its output variants.
28//!
29//! - **`params_hash`**: SHA-256 of the encoding parameters. For responsive
30//!   variants this includes (target width, quality). For thumbnails it includes
31//!   (aspect ratio, short edge, quality, sharpening). If any config value
32//!   changes, the params hash changes and the image is re-encoded.
33//!
34//! A cache hit requires:
35//! 1. An entry with matching `source_hash` and `params_hash` exists
36//! 2. The previously-written output file still exists on disk
37//!
38//! When a hit is found but the output path has changed (e.g. album renamed),
39//! the cached file is copied to the new location instead of re-encoding.
40//!
41//! ## Storage
42//!
43//! The cache manifest is a JSON file at `<output_dir>/.cache-manifest.json`.
44//! It lives alongside the processed images so it travels with the output
45//! directory when cached in CI (e.g. `actions/cache` on `dist/`).
46//!
47//! ## Bypassing the cache
48//!
49//! Pass `--no-cache` to the `build` or `process` command to force a full
50//! rebuild. This loads an empty manifest, so every image is re-encoded. The
51//! old output files are overwritten naturally.
52
53use sha2::{Digest, Sha256};
54use std::collections::{HashMap, HashSet};
55use std::fmt;
56use std::io;
57use std::path::{Path, PathBuf};
58
59/// Name of the cache manifest file within the output directory.
60const MANIFEST_FILENAME: &str = ".cache-manifest.json";
61
62/// Version of the cache manifest format. Bump this to invalidate all
63/// existing caches when the format or key computation changes.
64const MANIFEST_VERSION: u32 = 1;
65
66/// A single cached output file.
67#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
68pub struct CacheEntry {
69    pub source_hash: String,
70    pub params_hash: String,
71}
72
73/// On-disk cache manifest mapping output paths to their cache entries.
74///
75/// Lookups go through a runtime `content_index` that maps
76/// `"{source_hash}:{params_hash}"` to the stored output path, making
77/// the cache resilient to album renames and file renumbering.
78#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
79pub struct CacheManifest {
80    pub version: u32,
81    pub entries: HashMap<String, CacheEntry>,
82    /// Runtime reverse index: `"{source_hash}:{params_hash}"` → output_path.
83    /// Built at load time, maintained on insert. Never serialized.
84    #[serde(skip)]
85    content_index: HashMap<String, String>,
86}
87
88impl CacheManifest {
89    /// Create an empty manifest (used for `--no-cache` or first build).
90    pub fn empty() -> Self {
91        Self {
92            version: MANIFEST_VERSION,
93            entries: HashMap::new(),
94            content_index: HashMap::new(),
95        }
96    }
97
98    /// Load from the output directory. Returns an empty manifest if the
99    /// file doesn't exist or can't be parsed (version mismatch, corruption).
100    pub fn load(output_dir: &Path) -> Self {
101        let path = output_dir.join(MANIFEST_FILENAME);
102        let content = match std::fs::read_to_string(&path) {
103            Ok(c) => c,
104            Err(_) => return Self::empty(),
105        };
106        let mut manifest: Self = match serde_json::from_str(&content) {
107            Ok(m) => m,
108            Err(_) => return Self::empty(),
109        };
110        if manifest.version != MANIFEST_VERSION {
111            return Self::empty();
112        }
113        manifest.content_index = build_content_index(&manifest.entries);
114        manifest
115    }
116
117    /// Save to the output directory.
118    pub fn save(&self, output_dir: &Path) -> io::Result<()> {
119        let path = output_dir.join(MANIFEST_FILENAME);
120        let json = serde_json::to_string_pretty(self)?;
121        std::fs::write(path, json)
122    }
123
124    /// Look up a cached output file by content hashes.
125    ///
126    /// Returns `Some(stored_output_path)` if an entry with matching
127    /// `source_hash` and `params_hash` exists **and** the file is still
128    /// on disk. The returned path may differ from the caller's expected
129    /// output path (e.g. after an album rename); the caller is responsible
130    /// for copying the file to the new location if needed.
131    pub fn find_cached(
132        &self,
133        source_hash: &str,
134        params_hash: &str,
135        output_dir: &Path,
136    ) -> Option<String> {
137        let content_key = format!("{}:{}", source_hash, params_hash);
138        let stored_path = self.content_index.get(&content_key)?;
139        if output_dir.join(stored_path).exists() {
140            Some(stored_path.clone())
141        } else {
142            None
143        }
144    }
145
146    /// Record a cache entry for an output file.
147    ///
148    /// If an entry with the same content (source_hash + params_hash) already
149    /// exists under a different output path, the old entry is removed to keep
150    /// the manifest clean when images move (e.g. album rename).
151    ///
152    /// If the output path already has an entry for *different* content (e.g.
153    /// image swap: file A moved to where B used to be), the old content's
154    /// `content_index` entry is removed so stale lookups don't return a file
155    /// whose content has been overwritten.
156    pub fn insert(&mut self, output_path: String, source_hash: String, params_hash: String) {
157        let content_key = format!("{}:{}", source_hash, params_hash);
158
159        // Remove stale entry if content moved to a new path
160        if let Some(old_path) = self.content_index.get(&content_key)
161            && *old_path != output_path
162        {
163            self.entries.remove(old_path.as_str());
164        }
165
166        // If this output path previously held different content, invalidate
167        // that content's lookup entry — the file on disk no longer matches.
168        if let Some(displaced) = self.entries.get(&output_path) {
169            let displaced_key = format!("{}:{}", displaced.source_hash, displaced.params_hash);
170            if displaced_key != content_key {
171                self.content_index.remove(&displaced_key);
172            }
173        }
174
175        self.content_index.insert(content_key, output_path.clone());
176        self.entries.insert(
177            output_path,
178            CacheEntry {
179                source_hash,
180                params_hash,
181            },
182        );
183    }
184
185    /// Remove all entries whose output path is not in `live_paths`, and
186    /// delete the corresponding files from `output_dir`.
187    ///
188    /// Call this after a full build to clean up processed files for images
189    /// that were deleted, renumbered, or belong to renamed/removed albums.
190    pub fn prune(&mut self, live_paths: &HashSet<String>, output_dir: &Path) -> u32 {
191        let stale: Vec<String> = self
192            .entries
193            .keys()
194            .filter(|p| !live_paths.contains(p.as_str()))
195            .cloned()
196            .collect();
197
198        let mut removed = 0u32;
199        for path in &stale {
200            if let Some(entry) = self.entries.remove(path) {
201                let content_key = format!("{}:{}", entry.source_hash, entry.params_hash);
202                self.content_index.remove(&content_key);
203            }
204            let file = output_dir.join(path);
205            if file.exists() {
206                let _ = std::fs::remove_file(&file);
207            }
208            removed += 1;
209        }
210        removed
211    }
212}
213
214/// Build the content_index reverse map from the entries map.
215fn build_content_index(entries: &HashMap<String, CacheEntry>) -> HashMap<String, String> {
216    entries
217        .iter()
218        .map(|(output_path, entry)| {
219            let content_key = format!("{}:{}", entry.source_hash, entry.params_hash);
220            (content_key, output_path.clone())
221        })
222        .collect()
223}
224
225/// SHA-256 hash of a file's contents, returned as a hex string.
226pub fn hash_file(path: &Path) -> io::Result<String> {
227    let bytes = std::fs::read(path)?;
228    let digest = Sha256::digest(&bytes);
229    Ok(format!("{:x}", digest))
230}
231
232/// SHA-256 hash of encoding parameters for a responsive variant.
233///
234/// Inputs: target width and quality. If any of these change, the
235/// previously cached output is invalid.
236pub fn hash_responsive_params(target_width: u32, quality: u32) -> String {
237    let mut hasher = Sha256::new();
238    hasher.update(b"responsive\0");
239    hasher.update(target_width.to_le_bytes());
240    hasher.update(quality.to_le_bytes());
241    format!("{:x}", hasher.finalize())
242}
243
244/// SHA-256 hash of encoding parameters for a thumbnail.
245///
246/// Inputs: aspect ratio, short edge size, quality, and sharpening
247/// settings. If any of these change, the thumbnail is re-generated.
248pub fn hash_thumbnail_params(
249    aspect: (u32, u32),
250    short_edge: u32,
251    quality: u32,
252    sharpening: Option<(f32, i32)>,
253) -> String {
254    hash_thumbnail_variant_params(aspect, short_edge, quality, sharpening, "")
255}
256
257/// SHA-256 hash of encoding parameters for a named thumbnail variant.
258///
259/// When a single source image produces multiple thumbnails (e.g. the
260/// per-album thumbnail plus a full-index thumbnail), each variant must
261/// map to a distinct cache key even when ratio/size/quality/sharpening
262/// happen to match — otherwise `CacheManifest::insert` treats the second
263/// variant as a moved copy of the first and evicts its entry.
264///
265/// `variant` is a short discriminator (e.g. `"full-index"`). Passing an
266/// empty string reproduces the legacy `hash_thumbnail_params` hash, so
267/// existing per-album thumbnail caches are not invalidated.
268pub fn hash_thumbnail_variant_params(
269    aspect: (u32, u32),
270    short_edge: u32,
271    quality: u32,
272    sharpening: Option<(f32, i32)>,
273    variant: &str,
274) -> String {
275    let mut hasher = Sha256::new();
276    hasher.update(b"thumbnail\0");
277    hasher.update(aspect.0.to_le_bytes());
278    hasher.update(aspect.1.to_le_bytes());
279    hasher.update(short_edge.to_le_bytes());
280    hasher.update(quality.to_le_bytes());
281    match sharpening {
282        Some((sigma, threshold)) => {
283            hasher.update(b"\x01");
284            hasher.update(sigma.to_le_bytes());
285            hasher.update(threshold.to_le_bytes());
286        }
287        None => {
288            hasher.update(b"\x00");
289        }
290    }
291    if !variant.is_empty() {
292        hasher.update(b"\0variant\0");
293        hasher.update(variant.as_bytes());
294    }
295    format!("{:x}", hasher.finalize())
296}
297
298/// Summary of cache performance for a build run.
299#[derive(Debug, Default)]
300pub struct CacheStats {
301    pub hits: u32,
302    pub copies: u32,
303    pub misses: u32,
304}
305
306impl CacheStats {
307    pub fn hit(&mut self) {
308        self.hits += 1;
309    }
310
311    pub fn copy(&mut self) {
312        self.copies += 1;
313    }
314
315    pub fn miss(&mut self) {
316        self.misses += 1;
317    }
318
319    pub fn total(&self) -> u32 {
320        self.hits + self.copies + self.misses
321    }
322}
323
324impl fmt::Display for CacheStats {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        if self.hits > 0 || self.copies > 0 {
327            if self.copies > 0 {
328                write!(
329                    f,
330                    "{} cached, {} copied, {} encoded ({} total)",
331                    self.hits,
332                    self.copies,
333                    self.misses,
334                    self.total()
335                )
336            } else {
337                write!(
338                    f,
339                    "{} cached, {} encoded ({} total)",
340                    self.hits,
341                    self.misses,
342                    self.total()
343                )
344            }
345        } else {
346            write!(f, "{} encoded", self.misses)
347        }
348    }
349}
350
351/// Resolve the cache manifest path for an output directory.
352pub fn manifest_path(output_dir: &Path) -> PathBuf {
353    output_dir.join(MANIFEST_FILENAME)
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use std::fs;
360    use tempfile::TempDir;
361
362    // =========================================================================
363    // CacheManifest basics
364    // =========================================================================
365
366    #[test]
367    fn empty_manifest_has_no_entries() {
368        let m = CacheManifest::empty();
369        assert_eq!(m.version, MANIFEST_VERSION);
370        assert!(m.entries.is_empty());
371        assert!(m.content_index.is_empty());
372    }
373
374    #[test]
375    fn find_cached_hit() {
376        let tmp = TempDir::new().unwrap();
377        let mut m = CacheManifest::empty();
378        m.insert("a/b.avif".into(), "src123".into(), "prm456".into());
379
380        let out = tmp.path().join("a");
381        fs::create_dir_all(&out).unwrap();
382        fs::write(out.join("b.avif"), "data").unwrap();
383
384        assert_eq!(
385            m.find_cached("src123", "prm456", tmp.path()),
386            Some("a/b.avif".to_string())
387        );
388    }
389
390    #[test]
391    fn find_cached_miss_wrong_source_hash() {
392        let tmp = TempDir::new().unwrap();
393        let mut m = CacheManifest::empty();
394        m.insert("out.avif".into(), "hash_a".into(), "params".into());
395        fs::write(tmp.path().join("out.avif"), "data").unwrap();
396
397        assert_eq!(m.find_cached("hash_b", "params", tmp.path()), None);
398    }
399
400    #[test]
401    fn find_cached_miss_wrong_params_hash() {
402        let tmp = TempDir::new().unwrap();
403        let mut m = CacheManifest::empty();
404        m.insert("out.avif".into(), "hash".into(), "params_a".into());
405        fs::write(tmp.path().join("out.avif"), "data").unwrap();
406
407        assert_eq!(m.find_cached("hash", "params_b", tmp.path()), None);
408    }
409
410    #[test]
411    fn find_cached_miss_file_deleted() {
412        let mut m = CacheManifest::empty();
413        m.insert("gone.avif".into(), "h".into(), "p".into());
414        let tmp = TempDir::new().unwrap();
415        // File doesn't exist
416        assert_eq!(m.find_cached("h", "p", tmp.path()), None);
417    }
418
419    #[test]
420    fn find_cached_miss_no_entry() {
421        let m = CacheManifest::empty();
422        let tmp = TempDir::new().unwrap();
423        assert_eq!(m.find_cached("h", "p", tmp.path()), None);
424    }
425
426    #[test]
427    fn find_cached_returns_old_path_after_content_match() {
428        let tmp = TempDir::new().unwrap();
429        let mut m = CacheManifest::empty();
430        m.insert(
431            "old-album/01-800.avif".into(),
432            "srchash".into(),
433            "prmhash".into(),
434        );
435
436        let old_dir = tmp.path().join("old-album");
437        fs::create_dir_all(&old_dir).unwrap();
438        fs::write(old_dir.join("01-800.avif"), "avif data").unwrap();
439
440        let result = m.find_cached("srchash", "prmhash", tmp.path());
441        assert_eq!(result, Some("old-album/01-800.avif".to_string()));
442    }
443
444    #[test]
445    fn insert_removes_stale_entry_on_path_change() {
446        let mut m = CacheManifest::empty();
447        m.insert("old-album/img-800.avif".into(), "src".into(), "prm".into());
448        assert!(m.entries.contains_key("old-album/img-800.avif"));
449
450        // Insert same content under new path
451        m.insert("new-album/img-800.avif".into(), "src".into(), "prm".into());
452
453        assert!(!m.entries.contains_key("old-album/img-800.avif"));
454        assert!(m.entries.contains_key("new-album/img-800.avif"));
455    }
456
457    #[test]
458    fn insert_invalidates_displaced_content_index() {
459        let mut m = CacheManifest::empty();
460        // Path "album/309-800.avif" holds content A
461        m.insert(
462            "album/309-800.avif".into(),
463            "hash_A".into(),
464            "params".into(),
465        );
466        assert_eq!(
467            m.content_index.get("hash_A:params"),
468            Some(&"album/309-800.avif".to_string())
469        );
470
471        // Now content B overwrites that path (image swap)
472        m.insert(
473            "album/309-800.avif".into(),
474            "hash_B".into(),
475            "params".into(),
476        );
477
478        // hash_A's content_index entry should be gone (file overwritten)
479        assert_eq!(m.content_index.get("hash_A:params"), None);
480        // hash_B points to the path
481        assert_eq!(
482            m.content_index.get("hash_B:params"),
483            Some(&"album/309-800.avif".to_string())
484        );
485    }
486
487    #[test]
488    fn prune_removes_stale_entries_and_files() {
489        let tmp = TempDir::new().unwrap();
490        let mut m = CacheManifest::empty();
491        m.insert("album/live.avif".into(), "s1".into(), "p1".into());
492        m.insert("album/stale.avif".into(), "s2".into(), "p2".into());
493
494        // Create both files on disk
495        let dir = tmp.path().join("album");
496        fs::create_dir_all(&dir).unwrap();
497        fs::write(dir.join("live.avif"), "data").unwrap();
498        fs::write(dir.join("stale.avif"), "data").unwrap();
499
500        let mut live = HashSet::new();
501        live.insert("album/live.avif".to_string());
502        let removed = m.prune(&live, tmp.path());
503
504        assert_eq!(removed, 1);
505        assert!(m.entries.contains_key("album/live.avif"));
506        assert!(!m.entries.contains_key("album/stale.avif"));
507        assert!(dir.join("live.avif").exists());
508        assert!(!dir.join("stale.avif").exists());
509    }
510
511    #[test]
512    fn content_index_rebuilt_on_load() {
513        let tmp = TempDir::new().unwrap();
514        let mut m = CacheManifest::empty();
515        m.insert("a/x.avif".into(), "s1".into(), "p1".into());
516        m.insert("b/y.avif".into(), "s2".into(), "p2".into());
517        m.save(tmp.path()).unwrap();
518
519        let loaded = CacheManifest::load(tmp.path());
520        assert_eq!(
521            loaded.find_cached("s1", "p1", tmp.path()),
522            None // files don't exist, but index was built
523        );
524        assert_eq!(
525            loaded.content_index.get("s1:p1"),
526            Some(&"a/x.avif".to_string())
527        );
528        assert_eq!(
529            loaded.content_index.get("s2:p2"),
530            Some(&"b/y.avif".to_string())
531        );
532    }
533
534    // =========================================================================
535    // Save / Load roundtrip
536    // =========================================================================
537
538    #[test]
539    fn save_and_load_roundtrip() {
540        let tmp = TempDir::new().unwrap();
541        let mut m = CacheManifest::empty();
542        m.insert("x.avif".into(), "s1".into(), "p1".into());
543        m.insert("y.avif".into(), "s2".into(), "p2".into());
544
545        m.save(tmp.path()).unwrap();
546        let loaded = CacheManifest::load(tmp.path());
547
548        assert_eq!(loaded.version, MANIFEST_VERSION);
549        assert_eq!(loaded.entries.len(), 2);
550        assert_eq!(
551            loaded.entries["x.avif"],
552            CacheEntry {
553                source_hash: "s1".into(),
554                params_hash: "p1".into()
555            }
556        );
557    }
558
559    #[test]
560    fn load_missing_file_returns_empty() {
561        let tmp = TempDir::new().unwrap();
562        let m = CacheManifest::load(tmp.path());
563        assert!(m.entries.is_empty());
564    }
565
566    #[test]
567    fn load_corrupt_json_returns_empty() {
568        let tmp = TempDir::new().unwrap();
569        fs::write(tmp.path().join(MANIFEST_FILENAME), "not json").unwrap();
570        let m = CacheManifest::load(tmp.path());
571        assert!(m.entries.is_empty());
572    }
573
574    #[test]
575    fn load_wrong_version_returns_empty() {
576        let tmp = TempDir::new().unwrap();
577        let json = format!(
578            r#"{{"version": {}, "entries": {{"a": {{"source_hash":"h","params_hash":"p"}}}}}}"#,
579            MANIFEST_VERSION + 1
580        );
581        fs::write(tmp.path().join(MANIFEST_FILENAME), json).unwrap();
582        let m = CacheManifest::load(tmp.path());
583        assert!(m.entries.is_empty());
584    }
585
586    // =========================================================================
587    // Hash functions
588    // =========================================================================
589
590    #[test]
591    fn hash_file_deterministic() {
592        let tmp = TempDir::new().unwrap();
593        let path = tmp.path().join("test.bin");
594        fs::write(&path, b"hello world").unwrap();
595
596        let h1 = hash_file(&path).unwrap();
597        let h2 = hash_file(&path).unwrap();
598        assert_eq!(h1, h2);
599        assert_eq!(h1.len(), 64); // SHA-256 hex is 64 chars
600    }
601
602    #[test]
603    fn hash_file_changes_with_content() {
604        let tmp = TempDir::new().unwrap();
605        let path = tmp.path().join("test.bin");
606
607        fs::write(&path, b"version 1").unwrap();
608        let h1 = hash_file(&path).unwrap();
609
610        fs::write(&path, b"version 2").unwrap();
611        let h2 = hash_file(&path).unwrap();
612
613        assert_ne!(h1, h2);
614    }
615
616    #[test]
617    fn hash_responsive_params_deterministic() {
618        let h1 = hash_responsive_params(1400, 90);
619        let h2 = hash_responsive_params(1400, 90);
620        assert_eq!(h1, h2);
621    }
622
623    #[test]
624    fn hash_responsive_params_varies_with_width() {
625        assert_ne!(
626            hash_responsive_params(800, 90),
627            hash_responsive_params(1400, 90)
628        );
629    }
630
631    #[test]
632    fn hash_responsive_params_varies_with_quality() {
633        assert_ne!(
634            hash_responsive_params(800, 85),
635            hash_responsive_params(800, 90)
636        );
637    }
638
639    #[test]
640    fn hash_thumbnail_params_deterministic() {
641        let h1 = hash_thumbnail_params((4, 5), 400, 90, Some((0.5, 0)));
642        let h2 = hash_thumbnail_params((4, 5), 400, 90, Some((0.5, 0)));
643        assert_eq!(h1, h2);
644    }
645
646    #[test]
647    fn hash_thumbnail_params_varies_with_aspect() {
648        assert_ne!(
649            hash_thumbnail_params((4, 5), 400, 90, None),
650            hash_thumbnail_params((16, 9), 400, 90, None)
651        );
652    }
653
654    #[test]
655    fn hash_thumbnail_params_varies_with_sharpening() {
656        assert_ne!(
657            hash_thumbnail_params((4, 5), 400, 90, Some((0.5, 0))),
658            hash_thumbnail_params((4, 5), 400, 90, None)
659        );
660    }
661
662    #[test]
663    fn hash_thumbnail_variant_empty_tag_matches_legacy() {
664        // Passing an empty variant tag must produce the exact same hash as
665        // hash_thumbnail_params so existing per-album thumbnail caches are
666        // not silently invalidated by the new variant-aware call.
667        let legacy = hash_thumbnail_params((4, 5), 400, 90, Some((0.5, 0)));
668        let empty_tag = hash_thumbnail_variant_params((4, 5), 400, 90, Some((0.5, 0)), "");
669        assert_eq!(legacy, empty_tag);
670    }
671
672    #[test]
673    fn hash_thumbnail_variant_differs_from_untagged_even_when_settings_match() {
674        // Regression: when [full_index] and [thumbnails] use matching
675        // ratio/size/quality/sharpening, the two variants must still map
676        // to distinct cache keys so one doesn't evict the other on insert.
677        let regular = hash_thumbnail_params((4, 5), 400, 90, Some((0.5, 0)));
678        let full_index =
679            hash_thumbnail_variant_params((4, 5), 400, 90, Some((0.5, 0)), "full-index");
680        assert_ne!(regular, full_index);
681    }
682
683    #[test]
684    fn hash_thumbnail_variant_different_tags_differ() {
685        let a = hash_thumbnail_variant_params((4, 5), 400, 90, None, "full-index");
686        let b = hash_thumbnail_variant_params((4, 5), 400, 90, None, "print-sheet");
687        assert_ne!(a, b);
688    }
689
690    #[test]
691    fn insert_does_not_evict_regular_thumbnail_when_variant_tag_differs() {
692        // Full regression at the cache manifest level: inserting a regular
693        // thumbnail and then a variant thumbnail with matching encode
694        // settings must leave BOTH entries in the manifest.
695        let mut m = CacheManifest::empty();
696        let regular_hash = hash_thumbnail_variant_params((4, 5), 400, 90, None, "");
697        let fi_hash = hash_thumbnail_variant_params((4, 5), 400, 90, None, "full-index");
698
699        m.insert("a/001-test-thumb.avif".into(), "src".into(), regular_hash);
700        m.insert("a/001-test-fi-thumb.avif".into(), "src".into(), fi_hash);
701
702        assert!(m.entries.contains_key("a/001-test-thumb.avif"));
703        assert!(m.entries.contains_key("a/001-test-fi-thumb.avif"));
704    }
705
706    // =========================================================================
707    // CacheStats
708    // =========================================================================
709
710    #[test]
711    fn cache_stats_display_with_hits() {
712        let mut s = CacheStats::default();
713        s.hits = 5;
714        s.misses = 2;
715        assert_eq!(format!("{}", s), "5 cached, 2 encoded (7 total)");
716    }
717
718    #[test]
719    fn cache_stats_display_with_copies() {
720        let mut s = CacheStats::default();
721        s.hits = 3;
722        s.copies = 2;
723        s.misses = 1;
724        assert_eq!(format!("{}", s), "3 cached, 2 copied, 1 encoded (6 total)");
725    }
726
727    #[test]
728    fn cache_stats_display_no_hits() {
729        let mut s = CacheStats::default();
730        s.misses = 3;
731        assert_eq!(format!("{}", s), "3 encoded");
732    }
733}