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 std::sync::mpsc::Sender;
51use thiserror::Error;
52
53#[derive(Error, Debug)]
54pub enum ProcessError {
55 #[error("IO error: {0}")]
56 Io(#[from] std::io::Error),
57 #[error("JSON error: {0}")]
58 Json(#[from] serde_json::Error),
59 #[error("Image processing failed: {0}")]
60 Imaging(#[from] BackendError),
61 #[error("Source image not found: {0}")]
62 SourceNotFound(PathBuf),
63}
64
65#[derive(Debug, Clone)]
67pub struct ProcessConfig {
68 pub sizes: Vec<u32>,
69 pub quality: u32,
70 pub thumbnail_aspect: (u32, u32), pub thumbnail_size: u32, }
73
74impl ProcessConfig {
75 pub fn from_site_config(config: &SiteConfig) -> Self {
77 let ar = config.thumbnails.aspect_ratio;
78 Self {
79 sizes: config.images.sizes.clone(),
80 quality: config.images.quality,
81 thumbnail_aspect: (ar[0], ar[1]),
82 thumbnail_size: config.thumbnails.size,
83 }
84 }
85}
86
87impl Default for ProcessConfig {
88 fn default() -> Self {
89 Self::from_site_config(&SiteConfig::default())
90 }
91}
92
93#[derive(Debug, Deserialize)]
95pub struct InputManifest {
96 pub navigation: Vec<NavItem>,
97 pub albums: Vec<InputAlbum>,
98 #[serde(default)]
99 pub pages: Vec<Page>,
100 #[serde(default)]
101 pub description: Option<String>,
102 pub config: SiteConfig,
103}
104
105#[derive(Debug, Deserialize)]
106pub struct InputAlbum {
107 pub path: String,
108 pub title: String,
109 pub description: Option<String>,
110 pub preview_image: String,
111 pub images: Vec<InputImage>,
112 pub in_nav: bool,
113 pub config: SiteConfig,
114 #[serde(default)]
115 pub support_files: Vec<String>,
116}
117
118#[derive(Debug, Deserialize)]
119pub struct InputImage {
120 pub number: u32,
121 pub source_path: String,
122 pub filename: String,
123 #[serde(default)]
124 pub slug: String,
125 #[serde(default)]
126 pub title: Option<String>,
127 #[serde(default)]
128 pub description: Option<String>,
129}
130
131#[derive(Debug, Serialize)]
133pub struct OutputManifest {
134 pub navigation: Vec<NavItem>,
135 pub albums: Vec<OutputAlbum>,
136 #[serde(skip_serializing_if = "Vec::is_empty")]
137 pub pages: Vec<Page>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub description: Option<String>,
140 pub config: SiteConfig,
141}
142
143#[derive(Debug, Serialize)]
144pub struct OutputAlbum {
145 pub path: String,
146 pub title: String,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub description: Option<String>,
149 pub preview_image: String,
150 pub thumbnail: String,
151 pub images: Vec<OutputImage>,
152 pub in_nav: bool,
153 pub config: SiteConfig,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 pub support_files: Vec<String>,
156}
157
158#[derive(Debug, Serialize)]
159pub struct OutputImage {
160 pub number: u32,
161 pub source_path: String,
162 pub slug: String,
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub title: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub description: Option<String>,
167 pub dimensions: (u32, u32),
169 pub generated: std::collections::BTreeMap<String, GeneratedVariant>,
171 pub thumbnail: String,
173}
174
175#[derive(Debug, Serialize)]
176pub struct GeneratedVariant {
177 pub avif: String,
178 pub width: u32,
179 pub height: u32,
180}
181
182pub struct ProcessResult {
184 pub manifest: OutputManifest,
185 pub cache_stats: CacheStats,
186}
187
188#[derive(Debug, Clone)]
190pub enum VariantStatus {
191 Cached,
193 Copied,
195 Encoded,
197}
198
199impl From<&CacheLookup> for VariantStatus {
200 fn from(lookup: &CacheLookup) -> Self {
201 match lookup {
202 CacheLookup::ExactHit => VariantStatus::Cached,
203 CacheLookup::Copied => VariantStatus::Copied,
204 CacheLookup::Miss => VariantStatus::Encoded,
205 }
206 }
207}
208
209#[derive(Debug, Clone)]
211pub struct VariantInfo {
212 pub label: String,
214 pub status: VariantStatus,
216}
217
218#[derive(Debug, Clone)]
223pub enum ProcessEvent {
224 AlbumStarted { title: String, image_count: usize },
226 ImageProcessed {
228 index: usize,
230 title: Option<String>,
234 source_path: String,
236 variants: Vec<VariantInfo>,
238 },
239 CachePruned { removed: u32 },
241}
242
243pub fn process(
244 manifest_path: &Path,
245 source_root: &Path,
246 output_dir: &Path,
247 use_cache: bool,
248 progress: Option<Sender<ProcessEvent>>,
249) -> Result<ProcessResult, ProcessError> {
250 let backend = RustBackend::new();
251 process_with_backend(
252 &backend,
253 manifest_path,
254 source_root,
255 output_dir,
256 use_cache,
257 progress,
258 )
259}
260
261pub fn process_with_backend(
263 backend: &impl ImageBackend,
264 manifest_path: &Path,
265 source_root: &Path,
266 output_dir: &Path,
267 use_cache: bool,
268 progress: Option<Sender<ProcessEvent>>,
269) -> Result<ProcessResult, ProcessError> {
270 let manifest_content = std::fs::read_to_string(manifest_path)?;
271 let input: InputManifest = serde_json::from_str(&manifest_content)?;
272
273 std::fs::create_dir_all(output_dir)?;
274
275 let cache = Mutex::new(if use_cache {
276 CacheManifest::load(output_dir)
277 } else {
278 CacheManifest::empty()
279 });
280 let stats = Mutex::new(CacheStats::default());
281
282 let mut output_albums = Vec::new();
283
284 for album in &input.albums {
285 if let Some(ref tx) = progress {
286 tx.send(ProcessEvent::AlbumStarted {
287 title: album.title.clone(),
288 image_count: album.images.len(),
289 })
290 .ok();
291 }
292
293 let album_process = ProcessConfig::from_site_config(&album.config);
295
296 let responsive_config = ResponsiveConfig {
297 sizes: album_process.sizes.clone(),
298 quality: Quality::new(album_process.quality),
299 };
300
301 let thumbnail_config = ThumbnailConfig {
302 aspect: album_process.thumbnail_aspect,
303 short_edge: album_process.thumbnail_size,
304 quality: Quality::new(album_process.quality),
305 sharpening: Some(Sharpening::light()),
306 };
307 let album_output_dir = output_dir.join(&album.path);
308 std::fs::create_dir_all(&album_output_dir)?;
309
310 let processed_images: Result<Vec<_>, ProcessError> = album
312 .images
313 .par_iter()
314 .enumerate()
315 .map(|(idx, image)| {
316 let source_path = source_root.join(&image.source_path);
317 if !source_path.exists() {
318 return Err(ProcessError::SourceNotFound(source_path));
319 }
320
321 let dimensions = get_dimensions(backend, &source_path)?;
322
323 let exif = backend.read_metadata(&source_path)?;
326 let title = metadata::resolve(&[exif.title.as_deref(), image.title.as_deref()]);
327 let description =
328 metadata::resolve(&[image.description.as_deref(), exif.description.as_deref()]);
329 let slug = if exif.title.is_some() && title.is_some() {
330 metadata::sanitize_slug(title.as_deref().unwrap())
331 } else {
332 image.slug.clone()
333 };
334
335 let stem = Path::new(&image.filename)
336 .file_stem()
337 .unwrap()
338 .to_str()
339 .unwrap();
340
341 let source_hash = cache::hash_file(&source_path)?;
343 let ctx = CacheContext {
344 source_hash: &source_hash,
345 cache: &cache,
346 stats: &stats,
347 cache_root: output_dir,
348 };
349
350 let (raw_variants, responsive_statuses) = create_responsive_images_cached(
351 backend,
352 &source_path,
353 &album_output_dir,
354 stem,
355 dimensions,
356 &responsive_config,
357 &ctx,
358 )?;
359
360 let (thumbnail_path, thumb_status) = create_thumbnail_cached(
361 backend,
362 &source_path,
363 &album_output_dir,
364 stem,
365 &thumbnail_config,
366 &ctx,
367 )?;
368
369 let variant_infos: Vec<VariantInfo> = if progress.is_some() {
371 let mut infos: Vec<VariantInfo> = raw_variants
372 .iter()
373 .zip(&responsive_statuses)
374 .map(|(v, status)| VariantInfo {
375 label: format!("{}px", v.target_size),
376 status: status.clone(),
377 })
378 .collect();
379 infos.push(VariantInfo {
380 label: "thumbnail".to_string(),
381 status: thumb_status,
382 });
383 infos
384 } else {
385 Vec::new()
386 };
387
388 let generated: std::collections::BTreeMap<String, GeneratedVariant> = raw_variants
389 .into_iter()
390 .map(|v| {
391 (
392 v.target_size.to_string(),
393 GeneratedVariant {
394 avif: v.avif_path,
395 width: v.width,
396 height: v.height,
397 },
398 )
399 })
400 .collect();
401
402 if let Some(ref tx) = progress {
403 tx.send(ProcessEvent::ImageProcessed {
404 index: idx + 1,
405 title: title.clone(),
406 source_path: image.source_path.clone(),
407 variants: variant_infos,
408 })
409 .ok();
410 }
411
412 Ok((
413 image,
414 dimensions,
415 generated,
416 thumbnail_path,
417 title,
418 description,
419 slug,
420 ))
421 })
422 .collect();
423 let processed_images = processed_images?;
424
425 let mut output_images: Vec<OutputImage> = processed_images
427 .into_iter()
428 .map(
429 |(image, dimensions, generated, thumbnail_path, title, description, slug)| {
430 OutputImage {
431 number: image.number,
432 source_path: image.source_path.clone(),
433 slug,
434 title,
435 description,
436 dimensions,
437 generated,
438 thumbnail: thumbnail_path,
439 }
440 },
441 )
442 .collect();
443
444 output_images.sort_by_key(|img| img.number);
446
447 let album_thumbnail = output_images
449 .iter()
450 .find(|img| img.source_path == album.preview_image)
451 .expect("preview_image must be in the image list")
452 .thumbnail
453 .clone();
454
455 output_albums.push(OutputAlbum {
456 path: album.path.clone(),
457 title: album.title.clone(),
458 description: album.description.clone(),
459 preview_image: album.preview_image.clone(),
460 thumbnail: album_thumbnail,
461 images: output_images,
462 in_nav: album.in_nav,
463 config: album.config.clone(),
464 support_files: album.support_files.clone(),
465 });
466 }
467
468 let live_paths: std::collections::HashSet<String> = output_albums
470 .iter()
471 .flat_map(|album| {
472 let image_paths = album.images.iter().flat_map(|img| {
473 let mut paths: Vec<String> =
474 img.generated.values().map(|v| v.avif.clone()).collect();
475 paths.push(img.thumbnail.clone());
476 paths
477 });
478 std::iter::once(album.thumbnail.clone()).chain(image_paths)
479 })
480 .collect();
481
482 let mut final_cache = cache.into_inner().unwrap();
483 let pruned = final_cache.prune(&live_paths, output_dir);
484 let final_stats = stats.into_inner().unwrap();
485 final_cache.save(output_dir)?;
486
487 if let Some(ref tx) = progress
488 && pruned > 0
489 {
490 tx.send(ProcessEvent::CachePruned { removed: pruned }).ok();
491 }
492
493 Ok(ProcessResult {
494 manifest: OutputManifest {
495 navigation: input.navigation,
496 albums: output_albums,
497 pages: input.pages,
498 description: input.description,
499 config: input.config,
500 },
501 cache_stats: final_stats,
502 })
503}
504
505struct CacheContext<'a> {
507 source_hash: &'a str,
508 cache: &'a Mutex<CacheManifest>,
509 stats: &'a Mutex<CacheStats>,
510 cache_root: &'a Path,
511}
512
513enum CacheLookup {
515 ExactHit,
517 Copied,
519 Miss,
521}
522
523fn check_cache_and_copy(
534 expected_path: &str,
535 source_hash: &str,
536 params_hash: &str,
537 ctx: &CacheContext<'_>,
538) -> CacheLookup {
539 let mut cache = ctx.cache.lock().unwrap();
540 let cached_path = cache.find_cached(source_hash, params_hash, ctx.cache_root);
541
542 match cached_path {
543 Some(ref stored) if stored == expected_path => CacheLookup::ExactHit,
544 Some(ref stored) => {
545 let old_file = ctx.cache_root.join(stored);
546 let new_file = ctx.cache_root.join(expected_path);
547 if let Some(parent) = new_file.parent() {
548 let _ = std::fs::create_dir_all(parent);
549 }
550 match std::fs::copy(&old_file, &new_file) {
551 Ok(_) => {
552 cache.insert(
553 expected_path.to_string(),
554 source_hash.to_string(),
555 params_hash.to_string(),
556 );
557 CacheLookup::Copied
558 }
559 Err(_) => CacheLookup::Miss,
560 }
561 }
562 None => CacheLookup::Miss,
563 }
564}
565
566fn create_responsive_images_cached(
572 backend: &impl ImageBackend,
573 source: &Path,
574 output_dir: &Path,
575 filename_stem: &str,
576 original_dims: (u32, u32),
577 config: &ResponsiveConfig,
578 ctx: &CacheContext<'_>,
579) -> Result<
580 (
581 Vec<crate::imaging::operations::GeneratedVariant>,
582 Vec<VariantStatus>,
583 ),
584 ProcessError,
585> {
586 use crate::imaging::calculations::calculate_responsive_sizes;
587
588 let sizes = calculate_responsive_sizes(original_dims, &config.sizes);
589 let mut variants = Vec::new();
590 let mut statuses = Vec::new();
591
592 let relative_dir = output_dir
593 .strip_prefix(ctx.cache_root)
594 .unwrap()
595 .to_str()
596 .unwrap();
597
598 for size in sizes {
599 let avif_name = format!("{}-{}.avif", filename_stem, size.target);
600 let relative_path = format!("{}/{}", relative_dir, avif_name);
601 let params_hash = cache::hash_responsive_params(size.target, config.quality.value());
602
603 let lookup = check_cache_and_copy(&relative_path, ctx.source_hash, ¶ms_hash, ctx);
604 match &lookup {
605 CacheLookup::ExactHit => {
606 ctx.stats.lock().unwrap().hit();
607 }
608 CacheLookup::Copied => {
609 ctx.stats.lock().unwrap().copy();
610 }
611 CacheLookup::Miss => {
612 let avif_path = output_dir.join(&avif_name);
613 backend.resize(&crate::imaging::params::ResizeParams {
614 source: source.to_path_buf(),
615 output: avif_path,
616 width: size.width,
617 height: size.height,
618 quality: config.quality,
619 })?;
620 ctx.cache.lock().unwrap().insert(
621 relative_path.clone(),
622 ctx.source_hash.to_string(),
623 params_hash,
624 );
625 ctx.stats.lock().unwrap().miss();
626 }
627 }
628
629 statuses.push(VariantStatus::from(&lookup));
630 variants.push(crate::imaging::operations::GeneratedVariant {
631 target_size: size.target,
632 avif_path: relative_path,
633 width: size.width,
634 height: size.height,
635 });
636 }
637
638 Ok((variants, statuses))
639}
640
641fn create_thumbnail_cached(
643 backend: &impl ImageBackend,
644 source: &Path,
645 output_dir: &Path,
646 filename_stem: &str,
647 config: &ThumbnailConfig,
648 ctx: &CacheContext<'_>,
649) -> Result<(String, VariantStatus), ProcessError> {
650 let thumb_name = format!("{}-thumb.avif", filename_stem);
651 let relative_dir = output_dir
652 .strip_prefix(ctx.cache_root)
653 .unwrap()
654 .to_str()
655 .unwrap();
656 let relative_path = format!("{}/{}", relative_dir, thumb_name);
657
658 let sharpening_tuple = config.sharpening.map(|s| (s.sigma, s.threshold));
659 let params_hash = cache::hash_thumbnail_params(
660 config.aspect,
661 config.short_edge,
662 config.quality.value(),
663 sharpening_tuple,
664 );
665
666 let lookup = check_cache_and_copy(&relative_path, ctx.source_hash, ¶ms_hash, ctx);
667 match &lookup {
668 CacheLookup::ExactHit => {
669 ctx.stats.lock().unwrap().hit();
670 }
671 CacheLookup::Copied => {
672 ctx.stats.lock().unwrap().copy();
673 }
674 CacheLookup::Miss => {
675 let thumb_path = output_dir.join(&thumb_name);
676 let params = crate::imaging::operations::plan_thumbnail(source, &thumb_path, config);
677 backend.thumbnail(¶ms)?;
678 ctx.cache.lock().unwrap().insert(
679 relative_path.clone(),
680 ctx.source_hash.to_string(),
681 params_hash,
682 );
683 ctx.stats.lock().unwrap().miss();
684 }
685 }
686
687 let status = VariantStatus::from(&lookup);
688 Ok((relative_path, status))
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694 use std::fs;
695 use tempfile::TempDir;
696
697 #[test]
702 fn process_config_default_values() {
703 let config = ProcessConfig::default();
704
705 assert_eq!(config.sizes, vec![800, 1400, 2080]);
706 assert_eq!(config.quality, 90);
707 assert_eq!(config.thumbnail_aspect, (4, 5));
708 assert_eq!(config.thumbnail_size, 400);
709 }
710
711 #[test]
712 fn process_config_custom_values() {
713 let config = ProcessConfig {
714 sizes: vec![100, 200],
715 quality: 85,
716 thumbnail_aspect: (1, 1),
717 thumbnail_size: 150,
718 };
719
720 assert_eq!(config.sizes, vec![100, 200]);
721 assert_eq!(config.quality, 85);
722 assert_eq!(config.thumbnail_aspect, (1, 1));
723 assert_eq!(config.thumbnail_size, 150);
724 }
725
726 #[test]
731 fn parse_input_manifest() {
732 let manifest_json = r##"{
733 "navigation": [
734 {"title": "Album", "path": "010-album", "children": []}
735 ],
736 "albums": [{
737 "path": "010-album",
738 "title": "Album",
739 "description": "A test album",
740 "preview_image": "010-album/001-test.jpg",
741 "images": [{
742 "number": 1,
743 "source_path": "010-album/001-test.jpg",
744 "filename": "001-test.jpg"
745 }],
746 "in_nav": true,
747 "config": {}
748 }],
749 "pages": [{
750 "title": "About",
751 "link_title": "about",
752 "slug": "about",
753 "body": "# About\n\nContent",
754 "in_nav": true,
755 "sort_key": 40,
756 "is_link": false
757 }],
758 "config": {}
759 }"##;
760
761 let manifest: InputManifest = serde_json::from_str(manifest_json).unwrap();
762
763 assert_eq!(manifest.navigation.len(), 1);
764 assert_eq!(manifest.navigation[0].title, "Album");
765 assert_eq!(manifest.albums.len(), 1);
766 assert_eq!(manifest.albums[0].title, "Album");
767 assert_eq!(
768 manifest.albums[0].description,
769 Some("A test album".to_string())
770 );
771 assert_eq!(manifest.albums[0].images.len(), 1);
772 assert_eq!(manifest.pages.len(), 1);
773 assert_eq!(manifest.pages[0].title, "About");
774 }
775
776 #[test]
777 fn parse_manifest_without_pages() {
778 let manifest_json = r##"{
779 "navigation": [],
780 "albums": [],
781 "config": {}
782 }"##;
783
784 let manifest: InputManifest = serde_json::from_str(manifest_json).unwrap();
785 assert!(manifest.pages.is_empty());
786 }
787
788 #[test]
789 fn parse_nav_item_with_children() {
790 let json = r#"{
791 "title": "Travel",
792 "path": "020-travel",
793 "children": [
794 {"title": "Japan", "path": "020-travel/010-japan", "children": []},
795 {"title": "Italy", "path": "020-travel/020-italy", "children": []}
796 ]
797 }"#;
798
799 let item: NavItem = serde_json::from_str(json).unwrap();
800 assert_eq!(item.title, "Travel");
801 assert_eq!(item.children.len(), 2);
802 assert_eq!(item.children[0].title, "Japan");
803 }
804
805 use crate::imaging::Dimensions;
810 use crate::imaging::backend::tests::MockBackend;
811
812 fn create_test_manifest(tmp: &Path) -> PathBuf {
813 create_test_manifest_with_config(tmp, "{}")
814 }
815
816 fn create_test_manifest_with_config(tmp: &Path, album_config_json: &str) -> PathBuf {
817 let manifest = format!(
818 r##"{{
819 "navigation": [],
820 "albums": [{{
821 "path": "test-album",
822 "title": "Test Album",
823 "description": null,
824 "preview_image": "test-album/001-test.jpg",
825 "images": [{{
826 "number": 1,
827 "source_path": "test-album/001-test.jpg",
828 "filename": "001-test.jpg"
829 }}],
830 "in_nav": true,
831 "config": {album_config}
832 }}],
833 "config": {{}}
834 }}"##,
835 album_config = album_config_json,
836 );
837
838 let manifest_path = tmp.join("manifest.json");
839 fs::write(&manifest_path, manifest).unwrap();
840 manifest_path
841 }
842
843 fn create_dummy_source(path: &Path) {
844 fs::create_dir_all(path.parent().unwrap()).unwrap();
845 fs::write(path, "").unwrap();
847 }
848
849 #[test]
850 fn process_with_mock_generates_correct_outputs() {
851 let tmp = TempDir::new().unwrap();
852 let source_dir = tmp.path().join("source");
853 let output_dir = tmp.path().join("output");
854
855 let image_path = source_dir.join("test-album/001-test.jpg");
857 create_dummy_source(&image_path);
858
859 let manifest_path =
861 create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [100, 150]}}"#);
862
863 let backend = MockBackend::with_dimensions(vec![Dimensions {
865 width: 200,
866 height: 250,
867 }]);
868
869 let result = process_with_backend(
870 &backend,
871 &manifest_path,
872 &source_dir,
873 &output_dir,
874 false,
875 None,
876 )
877 .unwrap();
878
879 assert_eq!(result.manifest.albums.len(), 1);
881 assert_eq!(result.manifest.albums[0].images.len(), 1);
882
883 let image = &result.manifest.albums[0].images[0];
884 assert_eq!(image.dimensions, (200, 250));
885 assert!(!image.generated.is_empty());
886 assert!(!image.thumbnail.is_empty());
887 }
888
889 #[test]
890 fn process_with_mock_records_correct_operations() {
891 let tmp = TempDir::new().unwrap();
892 let source_dir = tmp.path().join("source");
893 let output_dir = tmp.path().join("output");
894
895 let image_path = source_dir.join("test-album/001-test.jpg");
896 create_dummy_source(&image_path);
897
898 let manifest_path = create_test_manifest_with_config(
900 tmp.path(),
901 r#"{"images": {"sizes": [800, 1400], "quality": 85}}"#,
902 );
903
904 let backend = MockBackend::with_dimensions(vec![Dimensions {
906 width: 2000,
907 height: 1500,
908 }]);
909
910 process_with_backend(
911 &backend,
912 &manifest_path,
913 &source_dir,
914 &output_dir,
915 false,
916 None,
917 )
918 .unwrap();
919
920 use crate::imaging::backend::tests::RecordedOp;
921 let ops = backend.get_operations();
922
923 assert_eq!(ops.len(), 5);
925
926 assert!(matches!(&ops[0], RecordedOp::Identify(_)));
928
929 assert!(matches!(&ops[1], RecordedOp::ReadMetadata(_)));
931
932 for op in &ops[2..4] {
934 assert!(matches!(op, RecordedOp::Resize { quality: 85, .. }));
935 }
936
937 assert!(matches!(&ops[4], RecordedOp::Thumbnail { .. }));
939 }
940
941 #[test]
942 fn process_with_mock_skips_larger_sizes() {
943 let tmp = TempDir::new().unwrap();
944 let source_dir = tmp.path().join("source");
945 let output_dir = tmp.path().join("output");
946
947 let image_path = source_dir.join("test-album/001-test.jpg");
948 create_dummy_source(&image_path);
949
950 let manifest_path = create_test_manifest_with_config(
952 tmp.path(),
953 r#"{"images": {"sizes": [800, 1400, 2080]}}"#,
954 );
955
956 let backend = MockBackend::with_dimensions(vec![Dimensions {
958 width: 500,
959 height: 400,
960 }]);
961
962 let result = process_with_backend(
963 &backend,
964 &manifest_path,
965 &source_dir,
966 &output_dir,
967 false,
968 None,
969 )
970 .unwrap();
971
972 let image = &result.manifest.albums[0].images[0];
974 assert_eq!(image.generated.len(), 1);
975 assert!(image.generated.contains_key("500"));
976 }
977
978 #[test]
979 fn process_source_not_found_error() {
980 let tmp = TempDir::new().unwrap();
981 let source_dir = tmp.path().join("source");
982 let output_dir = tmp.path().join("output");
983
984 let manifest_path = create_test_manifest(tmp.path());
986 let backend = MockBackend::new();
987
988 let result = process_with_backend(
989 &backend,
990 &manifest_path,
991 &source_dir,
992 &output_dir,
993 false,
994 None,
995 );
996
997 assert!(matches!(result, Err(ProcessError::SourceNotFound(_))));
998 }
999
1000 fn run_cached(
1006 source_dir: &Path,
1007 output_dir: &Path,
1008 manifest_path: &Path,
1009 dims: Vec<Dimensions>,
1010 ) -> (Vec<crate::imaging::backend::tests::RecordedOp>, CacheStats) {
1011 let backend = MockBackend::with_dimensions(dims);
1012 let result =
1013 process_with_backend(&backend, manifest_path, source_dir, output_dir, true, None)
1014 .unwrap();
1015 (backend.get_operations(), result.cache_stats)
1016 }
1017
1018 #[test]
1019 fn cache_second_run_skips_all_encoding() {
1020 let tmp = TempDir::new().unwrap();
1021 let source_dir = tmp.path().join("source");
1022 let output_dir = tmp.path().join("output");
1023
1024 let image_path = source_dir.join("test-album/001-test.jpg");
1025 create_dummy_source(&image_path);
1026
1027 let manifest_path = create_test_manifest_with_config(
1028 tmp.path(),
1029 r#"{"images": {"sizes": [800, 1400], "quality": 85}}"#,
1030 );
1031
1032 let (_ops1, stats1) = run_cached(
1034 &source_dir,
1035 &output_dir,
1036 &manifest_path,
1037 vec![Dimensions {
1038 width: 2000,
1039 height: 1500,
1040 }],
1041 );
1042
1043 for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1046 let path = output_dir.join(entry);
1047 fs::create_dir_all(path.parent().unwrap()).unwrap();
1048 fs::write(&path, "fake avif").unwrap();
1049 }
1050
1051 let (ops2, stats2) = run_cached(
1053 &source_dir,
1054 &output_dir,
1055 &manifest_path,
1056 vec![Dimensions {
1057 width: 2000,
1058 height: 1500,
1059 }],
1060 );
1061
1062 assert_eq!(stats1.misses, 3);
1064 assert_eq!(stats1.hits, 0);
1065
1066 assert_eq!(stats2.hits, 3);
1068 assert_eq!(stats2.misses, 0);
1069
1070 use crate::imaging::backend::tests::RecordedOp;
1072 let encode_ops: Vec<_> = ops2
1073 .iter()
1074 .filter(|op| matches!(op, RecordedOp::Resize { .. } | RecordedOp::Thumbnail { .. }))
1075 .collect();
1076 assert_eq!(encode_ops.len(), 0);
1077 }
1078
1079 #[test]
1080 fn cache_invalidated_when_source_changes() {
1081 let tmp = TempDir::new().unwrap();
1082 let source_dir = tmp.path().join("source");
1083 let output_dir = tmp.path().join("output");
1084
1085 let image_path = source_dir.join("test-album/001-test.jpg");
1086 create_dummy_source(&image_path);
1087
1088 let manifest_path =
1089 create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [800]}}"#);
1090
1091 let (_ops1, stats1) = run_cached(
1093 &source_dir,
1094 &output_dir,
1095 &manifest_path,
1096 vec![Dimensions {
1097 width: 2000,
1098 height: 1500,
1099 }],
1100 );
1101 assert_eq!(stats1.misses, 2); for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1105 let path = output_dir.join(entry);
1106 fs::create_dir_all(path.parent().unwrap()).unwrap();
1107 fs::write(&path, "fake").unwrap();
1108 }
1109
1110 fs::write(&image_path, "different content").unwrap();
1112
1113 let (_ops2, stats2) = run_cached(
1115 &source_dir,
1116 &output_dir,
1117 &manifest_path,
1118 vec![Dimensions {
1119 width: 2000,
1120 height: 1500,
1121 }],
1122 );
1123 assert_eq!(stats2.misses, 2);
1124 assert_eq!(stats2.hits, 0);
1125 }
1126
1127 #[test]
1128 fn cache_invalidated_when_config_changes() {
1129 let tmp = TempDir::new().unwrap();
1130 let source_dir = tmp.path().join("source");
1131 let output_dir = tmp.path().join("output");
1132
1133 let image_path = source_dir.join("test-album/001-test.jpg");
1134 create_dummy_source(&image_path);
1135
1136 let manifest_path = create_test_manifest_with_config(
1138 tmp.path(),
1139 r#"{"images": {"sizes": [800], "quality": 85}}"#,
1140 );
1141 let (_ops1, stats1) = run_cached(
1142 &source_dir,
1143 &output_dir,
1144 &manifest_path,
1145 vec![Dimensions {
1146 width: 2000,
1147 height: 1500,
1148 }],
1149 );
1150 assert_eq!(stats1.misses, 2);
1151
1152 for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1154 let path = output_dir.join(entry);
1155 fs::create_dir_all(path.parent().unwrap()).unwrap();
1156 fs::write(&path, "fake").unwrap();
1157 }
1158
1159 let manifest_path = create_test_manifest_with_config(
1161 tmp.path(),
1162 r#"{"images": {"sizes": [800], "quality": 90}}"#,
1163 );
1164 let (_ops2, stats2) = run_cached(
1165 &source_dir,
1166 &output_dir,
1167 &manifest_path,
1168 vec![Dimensions {
1169 width: 2000,
1170 height: 1500,
1171 }],
1172 );
1173 assert_eq!(stats2.misses, 2);
1174 assert_eq!(stats2.hits, 0);
1175 }
1176
1177 #[test]
1178 fn no_cache_flag_forces_full_reprocess() {
1179 let tmp = TempDir::new().unwrap();
1180 let source_dir = tmp.path().join("source");
1181 let output_dir = tmp.path().join("output");
1182
1183 let image_path = source_dir.join("test-album/001-test.jpg");
1184 create_dummy_source(&image_path);
1185
1186 let manifest_path =
1187 create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [800]}}"#);
1188
1189 let (_ops1, _stats1) = run_cached(
1191 &source_dir,
1192 &output_dir,
1193 &manifest_path,
1194 vec![Dimensions {
1195 width: 2000,
1196 height: 1500,
1197 }],
1198 );
1199
1200 for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1202 let path = output_dir.join(entry);
1203 fs::create_dir_all(path.parent().unwrap()).unwrap();
1204 fs::write(&path, "fake").unwrap();
1205 }
1206
1207 let backend = MockBackend::with_dimensions(vec![Dimensions {
1209 width: 2000,
1210 height: 1500,
1211 }]);
1212 let result = process_with_backend(
1213 &backend,
1214 &manifest_path,
1215 &source_dir,
1216 &output_dir,
1217 false,
1218 None,
1219 )
1220 .unwrap();
1221
1222 assert_eq!(result.cache_stats.misses, 2);
1224 assert_eq!(result.cache_stats.hits, 0);
1225 }
1226
1227 #[test]
1228 fn cache_hit_after_album_rename() {
1229 let tmp = TempDir::new().unwrap();
1230 let source_dir = tmp.path().join("source");
1231 let output_dir = tmp.path().join("output");
1232
1233 let image_path = source_dir.join("test-album/001-test.jpg");
1234 create_dummy_source(&image_path);
1235
1236 let manifest_path =
1238 create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [800]}}"#);
1239
1240 let (_ops1, stats1) = run_cached(
1241 &source_dir,
1242 &output_dir,
1243 &manifest_path,
1244 vec![Dimensions {
1245 width: 2000,
1246 height: 1500,
1247 }],
1248 );
1249 assert_eq!(stats1.misses, 2); assert_eq!(stats1.hits, 0);
1251
1252 for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1254 let path = output_dir.join(entry);
1255 fs::create_dir_all(path.parent().unwrap()).unwrap();
1256 fs::write(&path, "fake avif").unwrap();
1257 }
1258
1259 let manifest2 = r##"{
1261 "navigation": [],
1262 "albums": [{
1263 "path": "renamed-album",
1264 "title": "Renamed Album",
1265 "description": null,
1266 "preview_image": "test-album/001-test.jpg",
1267 "images": [{
1268 "number": 1,
1269 "source_path": "test-album/001-test.jpg",
1270 "filename": "001-test.jpg"
1271 }],
1272 "in_nav": true,
1273 "config": {"images": {"sizes": [800]}}
1274 }],
1275 "config": {}
1276 }"##;
1277 let manifest_path2 = tmp.path().join("manifest2.json");
1278 fs::write(&manifest_path2, manifest2).unwrap();
1279
1280 let backend = MockBackend::with_dimensions(vec![Dimensions {
1281 width: 2000,
1282 height: 1500,
1283 }]);
1284 let result = process_with_backend(
1285 &backend,
1286 &manifest_path2,
1287 &source_dir,
1288 &output_dir,
1289 true,
1290 None,
1291 )
1292 .unwrap();
1293
1294 assert_eq!(result.cache_stats.copies, 2); assert_eq!(result.cache_stats.misses, 0);
1297 assert_eq!(result.cache_stats.hits, 0);
1298
1299 assert!(output_dir.join("renamed-album/001-test-800.avif").exists());
1301 assert!(
1302 output_dir
1303 .join("renamed-album/001-test-thumb.avif")
1304 .exists()
1305 );
1306
1307 let manifest = cache::CacheManifest::load(&output_dir);
1309 assert!(
1310 !manifest
1311 .entries
1312 .contains_key("test-album/001-test-800.avif")
1313 );
1314 assert!(
1315 !manifest
1316 .entries
1317 .contains_key("test-album/001-test-thumb.avif")
1318 );
1319 assert!(
1320 manifest
1321 .entries
1322 .contains_key("renamed-album/001-test-800.avif")
1323 );
1324 assert!(
1325 manifest
1326 .entries
1327 .contains_key("renamed-album/001-test-thumb.avif")
1328 );
1329 }
1330}