1use crate::cache::{self, CacheManifest, CacheStats};
39use crate::config::SiteConfig;
40use crate::imaging::{
41 BackendError, ImageBackend, Quality, ResponsiveConfig, RustBackend, Sharpening,
42 ThumbnailConfig, get_dimensions,
43};
44use crate::metadata;
45use crate::types::{NavItem, Page};
46use rayon::prelude::*;
47use serde::{Deserialize, Serialize};
48use std::path::{Path, PathBuf};
49use std::sync::Mutex;
50use thiserror::Error;
51
52#[derive(Error, Debug)]
53pub enum ProcessError {
54 #[error("IO error: {0}")]
55 Io(#[from] std::io::Error),
56 #[error("JSON error: {0}")]
57 Json(#[from] serde_json::Error),
58 #[error("Image processing failed: {0}")]
59 Imaging(#[from] BackendError),
60 #[error("Source image not found: {0}")]
61 SourceNotFound(PathBuf),
62}
63
64#[derive(Debug, Clone)]
66pub struct ProcessConfig {
67 pub sizes: Vec<u32>,
68 pub quality: u32,
69 pub thumbnail_aspect: (u32, u32), pub thumbnail_size: u32, }
72
73impl ProcessConfig {
74 pub fn from_site_config(config: &SiteConfig) -> Self {
76 let ar = config.thumbnails.aspect_ratio;
77 Self {
78 sizes: config.images.sizes.clone(),
79 quality: config.images.quality,
80 thumbnail_aspect: (ar[0], ar[1]),
81 thumbnail_size: config.thumbnails.size,
82 }
83 }
84}
85
86impl Default for ProcessConfig {
87 fn default() -> Self {
88 Self::from_site_config(&SiteConfig::default())
89 }
90}
91
92#[derive(Debug, Deserialize)]
94pub struct InputManifest {
95 pub navigation: Vec<NavItem>,
96 pub albums: Vec<InputAlbum>,
97 #[serde(default)]
98 pub pages: Vec<Page>,
99 #[serde(default)]
100 pub description: Option<String>,
101 pub config: SiteConfig,
102}
103
104#[derive(Debug, Deserialize)]
105pub struct InputAlbum {
106 pub path: String,
107 pub title: String,
108 pub description: Option<String>,
109 pub preview_image: String,
110 pub images: Vec<InputImage>,
111 pub in_nav: bool,
112 pub config: SiteConfig,
113 #[serde(default)]
114 pub support_files: Vec<String>,
115}
116
117#[derive(Debug, Deserialize)]
118pub struct InputImage {
119 pub number: u32,
120 pub source_path: String,
121 pub filename: String,
122 #[serde(default)]
123 pub slug: String,
124 #[serde(default)]
125 pub title: Option<String>,
126 #[serde(default)]
127 pub description: Option<String>,
128}
129
130#[derive(Debug, Serialize)]
132pub struct OutputManifest {
133 pub navigation: Vec<NavItem>,
134 pub albums: Vec<OutputAlbum>,
135 #[serde(skip_serializing_if = "Vec::is_empty")]
136 pub pages: Vec<Page>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub description: Option<String>,
139 pub config: SiteConfig,
140}
141
142#[derive(Debug, Serialize)]
143pub struct OutputAlbum {
144 pub path: String,
145 pub title: String,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub description: Option<String>,
148 pub preview_image: String,
149 pub thumbnail: String,
150 pub images: Vec<OutputImage>,
151 pub in_nav: bool,
152 pub config: SiteConfig,
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub support_files: Vec<String>,
155}
156
157#[derive(Debug, Serialize)]
158pub struct OutputImage {
159 pub number: u32,
160 pub source_path: String,
161 pub slug: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub title: Option<String>,
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub description: Option<String>,
166 pub dimensions: (u32, u32),
168 pub generated: std::collections::BTreeMap<String, GeneratedVariant>,
170 pub thumbnail: String,
172}
173
174#[derive(Debug, Serialize)]
175pub struct GeneratedVariant {
176 pub avif: String,
177 pub width: u32,
178 pub height: u32,
179}
180
181pub struct ProcessResult {
183 pub manifest: OutputManifest,
184 pub cache_stats: CacheStats,
185}
186
187pub fn process(
188 manifest_path: &Path,
189 source_root: &Path,
190 output_dir: &Path,
191 use_cache: bool,
192) -> Result<ProcessResult, ProcessError> {
193 let backend = RustBackend::new();
194 process_with_backend(&backend, manifest_path, source_root, output_dir, use_cache)
195}
196
197pub fn process_with_backend(
199 backend: &impl ImageBackend,
200 manifest_path: &Path,
201 source_root: &Path,
202 output_dir: &Path,
203 use_cache: bool,
204) -> Result<ProcessResult, ProcessError> {
205 let manifest_content = std::fs::read_to_string(manifest_path)?;
206 let input: InputManifest = serde_json::from_str(&manifest_content)?;
207
208 std::fs::create_dir_all(output_dir)?;
209
210 let cache = Mutex::new(if use_cache {
211 CacheManifest::load(output_dir)
212 } else {
213 CacheManifest::empty()
214 });
215 let stats = Mutex::new(CacheStats::default());
216
217 let mut output_albums = Vec::new();
218
219 for album in &input.albums {
220 let album_process = ProcessConfig::from_site_config(&album.config);
222
223 let responsive_config = ResponsiveConfig {
224 sizes: album_process.sizes.clone(),
225 quality: Quality::new(album_process.quality),
226 };
227
228 let thumbnail_config = ThumbnailConfig {
229 aspect: album_process.thumbnail_aspect,
230 short_edge: album_process.thumbnail_size,
231 quality: Quality::new(album_process.quality),
232 sharpening: Some(Sharpening::light()),
233 };
234 let album_output_dir = output_dir.join(&album.path);
235 std::fs::create_dir_all(&album_output_dir)?;
236
237 let processed_images: Result<Vec<_>, ProcessError> = album
239 .images
240 .par_iter()
241 .map(|image| {
242 let source_path = source_root.join(&image.source_path);
243 if !source_path.exists() {
244 return Err(ProcessError::SourceNotFound(source_path));
245 }
246
247 let dimensions = get_dimensions(backend, &source_path)?;
248
249 let exif = backend.read_metadata(&source_path)?;
252 let title = metadata::resolve(&[exif.title.as_deref(), image.title.as_deref()]);
253 let description =
254 metadata::resolve(&[image.description.as_deref(), exif.description.as_deref()]);
255 let slug = if exif.title.is_some() && title.is_some() {
256 metadata::sanitize_slug(title.as_deref().unwrap())
257 } else {
258 image.slug.clone()
259 };
260
261 let stem = Path::new(&image.filename)
262 .file_stem()
263 .unwrap()
264 .to_str()
265 .unwrap();
266
267 let source_hash = cache::hash_file(&source_path)?;
269 let ctx = CacheContext {
270 source_hash: &source_hash,
271 cache: &cache,
272 stats: &stats,
273 cache_root: output_dir,
274 };
275
276 let variants = create_responsive_images_cached(
277 backend,
278 &source_path,
279 &album_output_dir,
280 stem,
281 dimensions,
282 &responsive_config,
283 &ctx,
284 )?;
285
286 let thumbnail_path = create_thumbnail_cached(
287 backend,
288 &source_path,
289 &album_output_dir,
290 stem,
291 &thumbnail_config,
292 &ctx,
293 )?;
294
295 let generated: std::collections::BTreeMap<String, GeneratedVariant> = variants
296 .into_iter()
297 .map(|v| {
298 (
299 v.target_size.to_string(),
300 GeneratedVariant {
301 avif: v.avif_path,
302 width: v.width,
303 height: v.height,
304 },
305 )
306 })
307 .collect();
308
309 Ok((
310 image,
311 dimensions,
312 generated,
313 thumbnail_path,
314 title,
315 description,
316 slug,
317 ))
318 })
319 .collect();
320 let processed_images = processed_images?;
321
322 let mut output_images: Vec<OutputImage> = processed_images
324 .into_iter()
325 .map(
326 |(image, dimensions, generated, thumbnail_path, title, description, slug)| {
327 OutputImage {
328 number: image.number,
329 source_path: image.source_path.clone(),
330 slug,
331 title,
332 description,
333 dimensions,
334 generated,
335 thumbnail: thumbnail_path,
336 }
337 },
338 )
339 .collect();
340
341 output_images.sort_by_key(|img| img.number);
343
344 let album_thumbnail = output_images
346 .iter()
347 .find(|img| img.source_path == album.preview_image)
348 .or_else(|| output_images.first())
349 .map(|img| img.thumbnail.clone())
350 .unwrap_or_default();
351
352 output_albums.push(OutputAlbum {
353 path: album.path.clone(),
354 title: album.title.clone(),
355 description: album.description.clone(),
356 preview_image: album.preview_image.clone(),
357 thumbnail: album_thumbnail,
358 images: output_images,
359 in_nav: album.in_nav,
360 config: album.config.clone(),
361 support_files: album.support_files.clone(),
362 });
363 }
364
365 let final_stats = stats.into_inner().unwrap();
366 cache.into_inner().unwrap().save(output_dir)?;
367
368 Ok(ProcessResult {
369 manifest: OutputManifest {
370 navigation: input.navigation,
371 albums: output_albums,
372 pages: input.pages,
373 description: input.description,
374 config: input.config,
375 },
376 cache_stats: final_stats,
377 })
378}
379
380struct CacheContext<'a> {
386 source_hash: &'a str,
387 cache: &'a Mutex<CacheManifest>,
388 stats: &'a Mutex<CacheStats>,
389 cache_root: &'a Path,
390}
391
392fn create_responsive_images_cached(
397 backend: &impl ImageBackend,
398 source: &Path,
399 output_dir: &Path,
400 filename_stem: &str,
401 original_dims: (u32, u32),
402 config: &ResponsiveConfig,
403 ctx: &CacheContext<'_>,
404) -> Result<Vec<crate::imaging::operations::GeneratedVariant>, ProcessError> {
405 use crate::imaging::calculations::calculate_responsive_sizes;
406
407 let sizes = calculate_responsive_sizes(original_dims, &config.sizes);
408 let mut variants = Vec::new();
409
410 let relative_dir = output_dir
411 .file_name()
412 .map(|s| s.to_str().unwrap())
413 .unwrap_or("");
414
415 for size in sizes {
416 let avif_name = format!("{}-{}.avif", filename_stem, size.target);
417 let relative_path = format!("{}/{}", relative_dir, avif_name);
418 let params_hash = cache::hash_responsive_params(size.target, config.quality.value());
419
420 let is_hit = ctx.cache.lock().unwrap().is_cached(
421 &relative_path,
422 ctx.source_hash,
423 ¶ms_hash,
424 ctx.cache_root,
425 );
426
427 if is_hit {
428 ctx.stats.lock().unwrap().hit();
429 } else {
430 let avif_path = output_dir.join(&avif_name);
431 backend.resize(&crate::imaging::params::ResizeParams {
432 source: source.to_path_buf(),
433 output: avif_path,
434 width: size.width,
435 height: size.height,
436 quality: config.quality,
437 })?;
438 let mut c = ctx.cache.lock().unwrap();
439 c.insert(
440 relative_path.clone(),
441 ctx.source_hash.to_string(),
442 params_hash,
443 );
444 ctx.stats.lock().unwrap().miss();
445 }
446
447 variants.push(crate::imaging::operations::GeneratedVariant {
448 target_size: size.target,
449 avif_path: relative_path,
450 width: size.width,
451 height: size.height,
452 });
453 }
454
455 Ok(variants)
456}
457
458fn create_thumbnail_cached(
460 backend: &impl ImageBackend,
461 source: &Path,
462 output_dir: &Path,
463 filename_stem: &str,
464 config: &ThumbnailConfig,
465 ctx: &CacheContext<'_>,
466) -> Result<String, ProcessError> {
467 let thumb_name = format!("{}-thumb.avif", filename_stem);
468 let relative_dir = output_dir
469 .file_name()
470 .map(|s| s.to_str().unwrap())
471 .unwrap_or("");
472 let relative_path = format!("{}/{}", relative_dir, thumb_name);
473
474 let sharpening_tuple = config.sharpening.map(|s| (s.sigma, s.threshold));
475 let params_hash = cache::hash_thumbnail_params(
476 config.aspect,
477 config.short_edge,
478 config.quality.value(),
479 sharpening_tuple,
480 );
481
482 let is_hit = ctx.cache.lock().unwrap().is_cached(
483 &relative_path,
484 ctx.source_hash,
485 ¶ms_hash,
486 ctx.cache_root,
487 );
488
489 if is_hit {
490 ctx.stats.lock().unwrap().hit();
491 } else {
492 let thumb_path = output_dir.join(&thumb_name);
493 let params = crate::imaging::operations::plan_thumbnail(source, &thumb_path, config);
494 backend.thumbnail(¶ms)?;
495 let mut c = ctx.cache.lock().unwrap();
496 c.insert(
497 relative_path.clone(),
498 ctx.source_hash.to_string(),
499 params_hash,
500 );
501 ctx.stats.lock().unwrap().miss();
502 }
503
504 Ok(relative_path)
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use std::fs;
511 use tempfile::TempDir;
512
513 #[test]
518 fn process_config_default_values() {
519 let config = ProcessConfig::default();
520
521 assert_eq!(config.sizes, vec![800, 1400, 2080]);
522 assert_eq!(config.quality, 90);
523 assert_eq!(config.thumbnail_aspect, (4, 5));
524 assert_eq!(config.thumbnail_size, 400);
525 }
526
527 #[test]
528 fn process_config_custom_values() {
529 let config = ProcessConfig {
530 sizes: vec![100, 200],
531 quality: 85,
532 thumbnail_aspect: (1, 1),
533 thumbnail_size: 150,
534 };
535
536 assert_eq!(config.sizes, vec![100, 200]);
537 assert_eq!(config.quality, 85);
538 assert_eq!(config.thumbnail_aspect, (1, 1));
539 assert_eq!(config.thumbnail_size, 150);
540 }
541
542 #[test]
547 fn parse_input_manifest() {
548 let manifest_json = r##"{
549 "navigation": [
550 {"title": "Album", "path": "010-album", "children": []}
551 ],
552 "albums": [{
553 "path": "010-album",
554 "title": "Album",
555 "description": "A test album",
556 "preview_image": "010-album/001-test.jpg",
557 "images": [{
558 "number": 1,
559 "source_path": "010-album/001-test.jpg",
560 "filename": "001-test.jpg"
561 }],
562 "in_nav": true,
563 "config": {}
564 }],
565 "pages": [{
566 "title": "About",
567 "link_title": "about",
568 "slug": "about",
569 "body": "# About\n\nContent",
570 "in_nav": true,
571 "sort_key": 40,
572 "is_link": false
573 }],
574 "config": {}
575 }"##;
576
577 let manifest: InputManifest = serde_json::from_str(manifest_json).unwrap();
578
579 assert_eq!(manifest.navigation.len(), 1);
580 assert_eq!(manifest.navigation[0].title, "Album");
581 assert_eq!(manifest.albums.len(), 1);
582 assert_eq!(manifest.albums[0].title, "Album");
583 assert_eq!(
584 manifest.albums[0].description,
585 Some("A test album".to_string())
586 );
587 assert_eq!(manifest.albums[0].images.len(), 1);
588 assert_eq!(manifest.pages.len(), 1);
589 assert_eq!(manifest.pages[0].title, "About");
590 }
591
592 #[test]
593 fn parse_manifest_without_pages() {
594 let manifest_json = r##"{
595 "navigation": [],
596 "albums": [],
597 "config": {}
598 }"##;
599
600 let manifest: InputManifest = serde_json::from_str(manifest_json).unwrap();
601 assert!(manifest.pages.is_empty());
602 }
603
604 #[test]
605 fn parse_nav_item_with_children() {
606 let json = r#"{
607 "title": "Travel",
608 "path": "020-travel",
609 "children": [
610 {"title": "Japan", "path": "020-travel/010-japan", "children": []},
611 {"title": "Italy", "path": "020-travel/020-italy", "children": []}
612 ]
613 }"#;
614
615 let item: NavItem = serde_json::from_str(json).unwrap();
616 assert_eq!(item.title, "Travel");
617 assert_eq!(item.children.len(), 2);
618 assert_eq!(item.children[0].title, "Japan");
619 }
620
621 use crate::imaging::Dimensions;
626 use crate::imaging::backend::tests::MockBackend;
627
628 fn create_test_manifest(tmp: &Path) -> PathBuf {
629 create_test_manifest_with_config(tmp, "{}")
630 }
631
632 fn create_test_manifest_with_config(tmp: &Path, album_config_json: &str) -> PathBuf {
633 let manifest = format!(
634 r##"{{
635 "navigation": [],
636 "albums": [{{
637 "path": "test-album",
638 "title": "Test Album",
639 "description": null,
640 "preview_image": "test-album/001-test.jpg",
641 "images": [{{
642 "number": 1,
643 "source_path": "test-album/001-test.jpg",
644 "filename": "001-test.jpg"
645 }}],
646 "in_nav": true,
647 "config": {album_config}
648 }}],
649 "config": {{}}
650 }}"##,
651 album_config = album_config_json,
652 );
653
654 let manifest_path = tmp.join("manifest.json");
655 fs::write(&manifest_path, manifest).unwrap();
656 manifest_path
657 }
658
659 fn create_dummy_source(path: &Path) {
660 fs::create_dir_all(path.parent().unwrap()).unwrap();
661 fs::write(path, "").unwrap();
663 }
664
665 #[test]
666 fn process_with_mock_generates_correct_outputs() {
667 let tmp = TempDir::new().unwrap();
668 let source_dir = tmp.path().join("source");
669 let output_dir = tmp.path().join("output");
670
671 let image_path = source_dir.join("test-album/001-test.jpg");
673 create_dummy_source(&image_path);
674
675 let manifest_path =
677 create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [100, 150]}}"#);
678
679 let backend = MockBackend::with_dimensions(vec![Dimensions {
681 width: 200,
682 height: 250,
683 }]);
684
685 let result =
686 process_with_backend(&backend, &manifest_path, &source_dir, &output_dir, false)
687 .unwrap();
688
689 assert_eq!(result.manifest.albums.len(), 1);
691 assert_eq!(result.manifest.albums[0].images.len(), 1);
692
693 let image = &result.manifest.albums[0].images[0];
694 assert_eq!(image.dimensions, (200, 250));
695 assert!(!image.generated.is_empty());
696 assert!(!image.thumbnail.is_empty());
697 }
698
699 #[test]
700 fn process_with_mock_records_correct_operations() {
701 let tmp = TempDir::new().unwrap();
702 let source_dir = tmp.path().join("source");
703 let output_dir = tmp.path().join("output");
704
705 let image_path = source_dir.join("test-album/001-test.jpg");
706 create_dummy_source(&image_path);
707
708 let manifest_path = create_test_manifest_with_config(
710 tmp.path(),
711 r#"{"images": {"sizes": [800, 1400], "quality": 85}}"#,
712 );
713
714 let backend = MockBackend::with_dimensions(vec![Dimensions {
716 width: 2000,
717 height: 1500,
718 }]);
719
720 process_with_backend(&backend, &manifest_path, &source_dir, &output_dir, false).unwrap();
721
722 use crate::imaging::backend::tests::RecordedOp;
723 let ops = backend.get_operations();
724
725 assert_eq!(ops.len(), 5);
727
728 assert!(matches!(&ops[0], RecordedOp::Identify(_)));
730
731 assert!(matches!(&ops[1], RecordedOp::ReadMetadata(_)));
733
734 for op in &ops[2..4] {
736 assert!(matches!(op, RecordedOp::Resize { quality: 85, .. }));
737 }
738
739 assert!(matches!(&ops[4], RecordedOp::Thumbnail { .. }));
741 }
742
743 #[test]
744 fn process_with_mock_skips_larger_sizes() {
745 let tmp = TempDir::new().unwrap();
746 let source_dir = tmp.path().join("source");
747 let output_dir = tmp.path().join("output");
748
749 let image_path = source_dir.join("test-album/001-test.jpg");
750 create_dummy_source(&image_path);
751
752 let manifest_path = create_test_manifest_with_config(
754 tmp.path(),
755 r#"{"images": {"sizes": [800, 1400, 2080]}}"#,
756 );
757
758 let backend = MockBackend::with_dimensions(vec![Dimensions {
760 width: 500,
761 height: 400,
762 }]);
763
764 let result =
765 process_with_backend(&backend, &manifest_path, &source_dir, &output_dir, false)
766 .unwrap();
767
768 let image = &result.manifest.albums[0].images[0];
770 assert_eq!(image.generated.len(), 1);
771 assert!(image.generated.contains_key("500"));
772 }
773
774 #[test]
775 fn process_source_not_found_error() {
776 let tmp = TempDir::new().unwrap();
777 let source_dir = tmp.path().join("source");
778 let output_dir = tmp.path().join("output");
779
780 let manifest_path = create_test_manifest(tmp.path());
782 let backend = MockBackend::new();
783
784 let result =
785 process_with_backend(&backend, &manifest_path, &source_dir, &output_dir, false);
786
787 assert!(matches!(result, Err(ProcessError::SourceNotFound(_))));
788 }
789
790 fn run_cached(
796 source_dir: &Path,
797 output_dir: &Path,
798 manifest_path: &Path,
799 dims: Vec<Dimensions>,
800 ) -> (Vec<crate::imaging::backend::tests::RecordedOp>, CacheStats) {
801 let backend = MockBackend::with_dimensions(dims);
802 let result =
803 process_with_backend(&backend, manifest_path, source_dir, output_dir, true).unwrap();
804 (backend.get_operations(), result.cache_stats)
805 }
806
807 #[test]
808 fn cache_second_run_skips_all_encoding() {
809 let tmp = TempDir::new().unwrap();
810 let source_dir = tmp.path().join("source");
811 let output_dir = tmp.path().join("output");
812
813 let image_path = source_dir.join("test-album/001-test.jpg");
814 create_dummy_source(&image_path);
815
816 let manifest_path = create_test_manifest_with_config(
817 tmp.path(),
818 r#"{"images": {"sizes": [800, 1400], "quality": 85}}"#,
819 );
820
821 let (_ops1, stats1) = run_cached(
823 &source_dir,
824 &output_dir,
825 &manifest_path,
826 vec![Dimensions {
827 width: 2000,
828 height: 1500,
829 }],
830 );
831
832 for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
835 let path = output_dir.join(entry);
836 fs::create_dir_all(path.parent().unwrap()).unwrap();
837 fs::write(&path, "fake avif").unwrap();
838 }
839
840 let (ops2, stats2) = run_cached(
842 &source_dir,
843 &output_dir,
844 &manifest_path,
845 vec![Dimensions {
846 width: 2000,
847 height: 1500,
848 }],
849 );
850
851 assert_eq!(stats1.misses, 3);
853 assert_eq!(stats1.hits, 0);
854
855 assert_eq!(stats2.hits, 3);
857 assert_eq!(stats2.misses, 0);
858
859 use crate::imaging::backend::tests::RecordedOp;
861 let encode_ops: Vec<_> = ops2
862 .iter()
863 .filter(|op| matches!(op, RecordedOp::Resize { .. } | RecordedOp::Thumbnail { .. }))
864 .collect();
865 assert_eq!(encode_ops.len(), 0);
866 }
867
868 #[test]
869 fn cache_invalidated_when_source_changes() {
870 let tmp = TempDir::new().unwrap();
871 let source_dir = tmp.path().join("source");
872 let output_dir = tmp.path().join("output");
873
874 let image_path = source_dir.join("test-album/001-test.jpg");
875 create_dummy_source(&image_path);
876
877 let manifest_path =
878 create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [800]}}"#);
879
880 let (_ops1, stats1) = run_cached(
882 &source_dir,
883 &output_dir,
884 &manifest_path,
885 vec![Dimensions {
886 width: 2000,
887 height: 1500,
888 }],
889 );
890 assert_eq!(stats1.misses, 2); for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
894 let path = output_dir.join(entry);
895 fs::create_dir_all(path.parent().unwrap()).unwrap();
896 fs::write(&path, "fake").unwrap();
897 }
898
899 fs::write(&image_path, "different content").unwrap();
901
902 let (_ops2, stats2) = run_cached(
904 &source_dir,
905 &output_dir,
906 &manifest_path,
907 vec![Dimensions {
908 width: 2000,
909 height: 1500,
910 }],
911 );
912 assert_eq!(stats2.misses, 2);
913 assert_eq!(stats2.hits, 0);
914 }
915
916 #[test]
917 fn cache_invalidated_when_config_changes() {
918 let tmp = TempDir::new().unwrap();
919 let source_dir = tmp.path().join("source");
920 let output_dir = tmp.path().join("output");
921
922 let image_path = source_dir.join("test-album/001-test.jpg");
923 create_dummy_source(&image_path);
924
925 let manifest_path = create_test_manifest_with_config(
927 tmp.path(),
928 r#"{"images": {"sizes": [800], "quality": 85}}"#,
929 );
930 let (_ops1, stats1) = run_cached(
931 &source_dir,
932 &output_dir,
933 &manifest_path,
934 vec![Dimensions {
935 width: 2000,
936 height: 1500,
937 }],
938 );
939 assert_eq!(stats1.misses, 2);
940
941 for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
943 let path = output_dir.join(entry);
944 fs::create_dir_all(path.parent().unwrap()).unwrap();
945 fs::write(&path, "fake").unwrap();
946 }
947
948 let manifest_path = create_test_manifest_with_config(
950 tmp.path(),
951 r#"{"images": {"sizes": [800], "quality": 90}}"#,
952 );
953 let (_ops2, stats2) = run_cached(
954 &source_dir,
955 &output_dir,
956 &manifest_path,
957 vec![Dimensions {
958 width: 2000,
959 height: 1500,
960 }],
961 );
962 assert_eq!(stats2.misses, 2);
963 assert_eq!(stats2.hits, 0);
964 }
965
966 #[test]
967 fn no_cache_flag_forces_full_reprocess() {
968 let tmp = TempDir::new().unwrap();
969 let source_dir = tmp.path().join("source");
970 let output_dir = tmp.path().join("output");
971
972 let image_path = source_dir.join("test-album/001-test.jpg");
973 create_dummy_source(&image_path);
974
975 let manifest_path =
976 create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [800]}}"#);
977
978 let (_ops1, _stats1) = run_cached(
980 &source_dir,
981 &output_dir,
982 &manifest_path,
983 vec![Dimensions {
984 width: 2000,
985 height: 1500,
986 }],
987 );
988
989 for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
991 let path = output_dir.join(entry);
992 fs::create_dir_all(path.parent().unwrap()).unwrap();
993 fs::write(&path, "fake").unwrap();
994 }
995
996 let backend = MockBackend::with_dimensions(vec![Dimensions {
998 width: 2000,
999 height: 1500,
1000 }]);
1001 let result =
1002 process_with_backend(&backend, &manifest_path, &source_dir, &output_dir, false)
1003 .unwrap();
1004
1005 assert_eq!(result.cache_stats.misses, 2);
1007 assert_eq!(result.cache_stats.hits, 0);
1008 }
1009}