1use sha2::{Digest, Sha256};
54use std::collections::{HashMap, HashSet};
55use std::fmt;
56use std::io;
57use std::path::{Path, PathBuf};
58
59const MANIFEST_FILENAME: &str = ".cache-manifest.json";
61
62const MANIFEST_VERSION: u32 = 1;
65
66#[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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
79pub struct CacheManifest {
80 pub version: u32,
81 pub entries: HashMap<String, CacheEntry>,
82 #[serde(skip)]
85 content_index: HashMap<String, String>,
86}
87
88impl CacheManifest {
89 pub fn empty() -> Self {
91 Self {
92 version: MANIFEST_VERSION,
93 entries: HashMap::new(),
94 content_index: HashMap::new(),
95 }
96 }
97
98 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 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 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 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 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 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 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
214fn 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
225pub 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
232pub 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
244pub 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
257pub 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#[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
351pub 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 #[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 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 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 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 m.insert(
473 "album/309-800.avif".into(),
474 "hash_B".into(),
475 "params".into(),
476 );
477
478 assert_eq!(m.content_index.get("hash_A:params"), None);
480 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 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 );
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 #[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 #[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); }
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 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 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 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 #[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}