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 #[serde(skip_serializing_if = "Option::is_none")]
176 pub full_index_thumbnail: Option<String>,
177}
178
179#[derive(Debug, Serialize)]
180pub struct GeneratedVariant {
181 pub avif: String,
182 pub width: u32,
183 pub height: u32,
184}
185
186pub struct ProcessResult {
188 pub manifest: OutputManifest,
189 pub cache_stats: CacheStats,
190}
191
192#[derive(Debug, Clone)]
194pub enum VariantStatus {
195 Cached,
197 Copied,
199 Encoded,
201}
202
203impl From<&CacheLookup> for VariantStatus {
204 fn from(lookup: &CacheLookup) -> Self {
205 match lookup {
206 CacheLookup::ExactHit => VariantStatus::Cached,
207 CacheLookup::Copied => VariantStatus::Copied,
208 CacheLookup::Miss => VariantStatus::Encoded,
209 }
210 }
211}
212
213#[derive(Debug, Clone)]
215pub struct VariantInfo {
216 pub label: String,
218 pub status: VariantStatus,
220}
221
222#[derive(Debug, Clone)]
227pub enum ProcessEvent {
228 AlbumStarted { title: String, image_count: usize },
230 ImageProcessed {
232 index: usize,
234 title: Option<String>,
238 source_path: String,
240 variants: Vec<VariantInfo>,
242 },
243 CachePruned { removed: u32 },
245}
246
247pub fn process(
248 manifest_path: &Path,
249 source_root: &Path,
250 output_dir: &Path,
251 use_cache: bool,
252 progress: Option<Sender<ProcessEvent>>,
253) -> Result<ProcessResult, ProcessError> {
254 let backend = RustBackend::new();
255 process_with_backend(
256 &backend,
257 manifest_path,
258 source_root,
259 output_dir,
260 use_cache,
261 progress,
262 )
263}
264
265pub fn process_with_backend(
267 backend: &impl ImageBackend,
268 manifest_path: &Path,
269 source_root: &Path,
270 output_dir: &Path,
271 use_cache: bool,
272 progress: Option<Sender<ProcessEvent>>,
273) -> Result<ProcessResult, ProcessError> {
274 let manifest_content = std::fs::read_to_string(manifest_path)?;
275 let input: InputManifest = serde_json::from_str(&manifest_content)?;
276
277 std::fs::create_dir_all(output_dir)?;
278
279 let cache = Mutex::new(if use_cache {
280 CacheManifest::load(output_dir)
281 } else {
282 CacheManifest::empty()
283 });
284 let stats = Mutex::new(CacheStats::default());
285
286 let mut output_albums = Vec::new();
287
288 for album in &input.albums {
289 if let Some(ref tx) = progress {
290 tx.send(ProcessEvent::AlbumStarted {
291 title: album.title.clone(),
292 image_count: album.images.len(),
293 })
294 .ok();
295 }
296
297 let album_process = ProcessConfig::from_site_config(&album.config);
299
300 let responsive_config = ResponsiveConfig {
301 sizes: album_process.sizes.clone(),
302 quality: Quality::new(album_process.quality),
303 };
304
305 let thumbnail_config = ThumbnailConfig {
306 aspect: album_process.thumbnail_aspect,
307 short_edge: album_process.thumbnail_size,
308 quality: Quality::new(album_process.quality),
309 sharpening: Some(Sharpening::light()),
310 };
311
312 let full_index_thumbnail_config: Option<ThumbnailConfig> =
316 if input.config.full_index.generates {
317 let fi = &input.config.full_index;
318 Some(ThumbnailConfig {
319 aspect: (fi.thumb_ratio[0], fi.thumb_ratio[1]),
320 short_edge: fi.thumb_size,
321 quality: Quality::new(album_process.quality),
322 sharpening: Some(Sharpening::light()),
323 })
324 } else {
325 None
326 };
327 let album_output_dir = output_dir.join(&album.path);
328 std::fs::create_dir_all(&album_output_dir)?;
329
330 let processed_images: Result<Vec<_>, ProcessError> = album
332 .images
333 .par_iter()
334 .enumerate()
335 .map(|(idx, image)| {
336 let source_path = source_root.join(&image.source_path);
337 if !source_path.exists() {
338 return Err(ProcessError::SourceNotFound(source_path));
339 }
340
341 let dimensions = get_dimensions(backend, &source_path)?;
342
343 let exif = backend.read_metadata(&source_path)?;
346 let title = metadata::resolve(&[exif.title.as_deref(), image.title.as_deref()]);
347 let description =
348 metadata::resolve(&[image.description.as_deref(), exif.description.as_deref()]);
349 let slug = if exif.title.is_some() && title.is_some() {
350 metadata::sanitize_slug(title.as_deref().unwrap())
351 } else {
352 image.slug.clone()
353 };
354
355 let stem = Path::new(&image.filename)
356 .file_stem()
357 .unwrap()
358 .to_str()
359 .unwrap();
360
361 let source_hash = cache::hash_file(&source_path)?;
363 let ctx = CacheContext {
364 source_hash: &source_hash,
365 cache: &cache,
366 stats: &stats,
367 cache_root: output_dir,
368 };
369
370 let (raw_variants, responsive_statuses) = create_responsive_images_cached(
371 backend,
372 &source_path,
373 &album_output_dir,
374 stem,
375 dimensions,
376 &responsive_config,
377 &ctx,
378 )?;
379
380 let (thumbnail_path, thumb_status) = create_thumbnail_cached(
381 backend,
382 &source_path,
383 &album_output_dir,
384 stem,
385 &thumbnail_config,
386 &ctx,
387 )?;
388
389 let full_index_thumb = if let Some(ref fi_cfg) = full_index_thumbnail_config {
390 let (path, status) = create_thumbnail_cached_with_suffix(
391 backend,
392 &source_path,
393 &album_output_dir,
394 stem,
395 "fi-thumb",
396 "full-index",
397 fi_cfg,
398 &ctx,
399 )?;
400 Some((path, status))
401 } else {
402 None
403 };
404
405 let variant_infos: Vec<VariantInfo> = if progress.is_some() {
407 let mut infos: Vec<VariantInfo> = raw_variants
408 .iter()
409 .zip(&responsive_statuses)
410 .map(|(v, status)| VariantInfo {
411 label: format!("{}px", v.target_size),
412 status: status.clone(),
413 })
414 .collect();
415 infos.push(VariantInfo {
416 label: "thumbnail".to_string(),
417 status: thumb_status,
418 });
419 if let Some((_, ref fi_status)) = full_index_thumb {
420 infos.push(VariantInfo {
421 label: "all-photos thumbnail".to_string(),
422 status: fi_status.clone(),
423 });
424 }
425 infos
426 } else {
427 Vec::new()
428 };
429
430 let generated: std::collections::BTreeMap<String, GeneratedVariant> = raw_variants
431 .into_iter()
432 .map(|v| {
433 (
434 v.target_size.to_string(),
435 GeneratedVariant {
436 avif: v.avif_path,
437 width: v.width,
438 height: v.height,
439 },
440 )
441 })
442 .collect();
443
444 if let Some(ref tx) = progress {
445 tx.send(ProcessEvent::ImageProcessed {
446 index: idx + 1,
447 title: title.clone(),
448 source_path: image.source_path.clone(),
449 variants: variant_infos,
450 })
451 .ok();
452 }
453
454 Ok((
455 image,
456 dimensions,
457 generated,
458 thumbnail_path,
459 full_index_thumb.map(|(p, _)| p),
460 title,
461 description,
462 slug,
463 ))
464 })
465 .collect();
466 let processed_images = processed_images?;
467
468 let mut output_images: Vec<OutputImage> = processed_images
470 .into_iter()
471 .map(
472 |(
473 image,
474 dimensions,
475 generated,
476 thumbnail_path,
477 full_index_thumbnail,
478 title,
479 description,
480 slug,
481 )| {
482 OutputImage {
483 number: image.number,
484 source_path: image.source_path.clone(),
485 slug,
486 title,
487 description,
488 dimensions,
489 generated,
490 thumbnail: thumbnail_path,
491 full_index_thumbnail,
492 }
493 },
494 )
495 .collect();
496
497 output_images.sort_by_key(|img| img.number);
499
500 let album_thumbnail = output_images
502 .iter()
503 .find(|img| img.source_path == album.preview_image)
504 .expect("preview_image must be in the image list")
505 .thumbnail
506 .clone();
507
508 output_albums.push(OutputAlbum {
509 path: album.path.clone(),
510 title: album.title.clone(),
511 description: album.description.clone(),
512 preview_image: album.preview_image.clone(),
513 thumbnail: album_thumbnail,
514 images: output_images,
515 in_nav: album.in_nav,
516 config: album.config.clone(),
517 support_files: album.support_files.clone(),
518 });
519 }
520
521 let live_paths: std::collections::HashSet<String> = output_albums
523 .iter()
524 .flat_map(|album| {
525 let image_paths = album.images.iter().flat_map(|img| {
526 let mut paths: Vec<String> =
527 img.generated.values().map(|v| v.avif.clone()).collect();
528 paths.push(img.thumbnail.clone());
529 if let Some(ref fi) = img.full_index_thumbnail {
530 paths.push(fi.clone());
531 }
532 paths
533 });
534 std::iter::once(album.thumbnail.clone()).chain(image_paths)
535 })
536 .collect();
537
538 let mut final_cache = cache.into_inner().unwrap();
539 let pruned = final_cache.prune(&live_paths, output_dir);
540 let final_stats = stats.into_inner().unwrap();
541 final_cache.save(output_dir)?;
542
543 if let Some(ref tx) = progress
544 && pruned > 0
545 {
546 tx.send(ProcessEvent::CachePruned { removed: pruned }).ok();
547 }
548
549 Ok(ProcessResult {
550 manifest: OutputManifest {
551 navigation: input.navigation,
552 albums: output_albums,
553 pages: input.pages,
554 description: input.description,
555 config: input.config,
556 },
557 cache_stats: final_stats,
558 })
559}
560
561struct CacheContext<'a> {
563 source_hash: &'a str,
564 cache: &'a Mutex<CacheManifest>,
565 stats: &'a Mutex<CacheStats>,
566 cache_root: &'a Path,
567}
568
569enum CacheLookup {
571 ExactHit,
573 Copied,
575 Miss,
577}
578
579fn check_cache_and_copy(
590 expected_path: &str,
591 source_hash: &str,
592 params_hash: &str,
593 ctx: &CacheContext<'_>,
594) -> CacheLookup {
595 let mut cache = ctx.cache.lock().unwrap();
596 let cached_path = cache.find_cached(source_hash, params_hash, ctx.cache_root);
597
598 match cached_path {
599 Some(ref stored) if stored == expected_path => CacheLookup::ExactHit,
600 Some(ref stored) => {
601 let old_file = ctx.cache_root.join(stored);
602 let new_file = ctx.cache_root.join(expected_path);
603 if let Some(parent) = new_file.parent() {
604 let _ = std::fs::create_dir_all(parent);
605 }
606 match std::fs::copy(&old_file, &new_file) {
607 Ok(_) => {
608 cache.insert(
609 expected_path.to_string(),
610 source_hash.to_string(),
611 params_hash.to_string(),
612 );
613 CacheLookup::Copied
614 }
615 Err(_) => CacheLookup::Miss,
616 }
617 }
618 None => CacheLookup::Miss,
619 }
620}
621
622fn create_responsive_images_cached(
628 backend: &impl ImageBackend,
629 source: &Path,
630 output_dir: &Path,
631 filename_stem: &str,
632 original_dims: (u32, u32),
633 config: &ResponsiveConfig,
634 ctx: &CacheContext<'_>,
635) -> Result<
636 (
637 Vec<crate::imaging::operations::GeneratedVariant>,
638 Vec<VariantStatus>,
639 ),
640 ProcessError,
641> {
642 use crate::imaging::calculations::calculate_responsive_sizes;
643
644 let sizes = calculate_responsive_sizes(original_dims, &config.sizes);
645 let mut variants = Vec::new();
646 let mut statuses = Vec::new();
647
648 let relative_dir = output_dir
649 .strip_prefix(ctx.cache_root)
650 .unwrap()
651 .to_str()
652 .unwrap();
653
654 for size in sizes {
655 let avif_name = format!("{}-{}.avif", filename_stem, size.target);
656 let relative_path = format!("{}/{}", relative_dir, avif_name);
657 let params_hash = cache::hash_responsive_params(size.target, config.quality.value());
658
659 let lookup = check_cache_and_copy(&relative_path, ctx.source_hash, ¶ms_hash, ctx);
660 match &lookup {
661 CacheLookup::ExactHit => {
662 ctx.stats.lock().unwrap().hit();
663 }
664 CacheLookup::Copied => {
665 ctx.stats.lock().unwrap().copy();
666 }
667 CacheLookup::Miss => {
668 let avif_path = output_dir.join(&avif_name);
669 backend.resize(&crate::imaging::params::ResizeParams {
670 source: source.to_path_buf(),
671 output: avif_path,
672 width: size.width,
673 height: size.height,
674 quality: config.quality,
675 })?;
676 ctx.cache.lock().unwrap().insert(
677 relative_path.clone(),
678 ctx.source_hash.to_string(),
679 params_hash,
680 );
681 ctx.stats.lock().unwrap().miss();
682 }
683 }
684
685 statuses.push(VariantStatus::from(&lookup));
686 variants.push(crate::imaging::operations::GeneratedVariant {
687 target_size: size.target,
688 avif_path: relative_path,
689 width: size.width,
690 height: size.height,
691 });
692 }
693
694 Ok((variants, statuses))
695}
696
697fn create_thumbnail_cached(
699 backend: &impl ImageBackend,
700 source: &Path,
701 output_dir: &Path,
702 filename_stem: &str,
703 config: &ThumbnailConfig,
704 ctx: &CacheContext<'_>,
705) -> Result<(String, VariantStatus), ProcessError> {
706 create_thumbnail_cached_with_suffix(
707 backend,
708 source,
709 output_dir,
710 filename_stem,
711 "thumb",
712 "",
713 config,
714 ctx,
715 )
716}
717
718#[allow(clippy::too_many_arguments)]
728fn create_thumbnail_cached_with_suffix(
729 backend: &impl ImageBackend,
730 source: &Path,
731 output_dir: &Path,
732 filename_stem: &str,
733 suffix: &str,
734 variant_tag: &str,
735 config: &ThumbnailConfig,
736 ctx: &CacheContext<'_>,
737) -> Result<(String, VariantStatus), ProcessError> {
738 let thumb_name = format!("{}-{}.avif", filename_stem, suffix);
739 let relative_dir = output_dir
740 .strip_prefix(ctx.cache_root)
741 .unwrap()
742 .to_str()
743 .unwrap();
744 let relative_path = format!("{}/{}", relative_dir, thumb_name);
745
746 let sharpening_tuple = config.sharpening.map(|s| (s.sigma, s.threshold));
747 let params_hash = cache::hash_thumbnail_variant_params(
748 config.aspect,
749 config.short_edge,
750 config.quality.value(),
751 sharpening_tuple,
752 variant_tag,
753 );
754
755 let lookup = check_cache_and_copy(&relative_path, ctx.source_hash, ¶ms_hash, ctx);
756 match &lookup {
757 CacheLookup::ExactHit => {
758 ctx.stats.lock().unwrap().hit();
759 }
760 CacheLookup::Copied => {
761 ctx.stats.lock().unwrap().copy();
762 }
763 CacheLookup::Miss => {
764 let thumb_path = output_dir.join(&thumb_name);
765 let params = crate::imaging::operations::plan_thumbnail(source, &thumb_path, config);
766 backend.thumbnail(¶ms)?;
767 ctx.cache.lock().unwrap().insert(
768 relative_path.clone(),
769 ctx.source_hash.to_string(),
770 params_hash,
771 );
772 ctx.stats.lock().unwrap().miss();
773 }
774 }
775
776 let status = VariantStatus::from(&lookup);
777 Ok((relative_path, status))
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783 use std::fs;
784 use tempfile::TempDir;
785
786 #[test]
791 fn process_config_default_values() {
792 let config = ProcessConfig::default();
793
794 assert_eq!(config.sizes, vec![800, 1400, 2080]);
795 assert_eq!(config.quality, 90);
796 assert_eq!(config.thumbnail_aspect, (4, 5));
797 assert_eq!(config.thumbnail_size, 400);
798 }
799
800 #[test]
801 fn process_config_custom_values() {
802 let config = ProcessConfig {
803 sizes: vec![100, 200],
804 quality: 85,
805 thumbnail_aspect: (1, 1),
806 thumbnail_size: 150,
807 };
808
809 assert_eq!(config.sizes, vec![100, 200]);
810 assert_eq!(config.quality, 85);
811 assert_eq!(config.thumbnail_aspect, (1, 1));
812 assert_eq!(config.thumbnail_size, 150);
813 }
814
815 #[test]
820 fn parse_input_manifest() {
821 let manifest_json = r##"{
822 "navigation": [
823 {"title": "Album", "path": "010-album", "children": []}
824 ],
825 "albums": [{
826 "path": "010-album",
827 "title": "Album",
828 "description": "A test album",
829 "preview_image": "010-album/001-test.jpg",
830 "images": [{
831 "number": 1,
832 "source_path": "010-album/001-test.jpg",
833 "filename": "001-test.jpg"
834 }],
835 "in_nav": true,
836 "config": {}
837 }],
838 "pages": [{
839 "title": "About",
840 "link_title": "about",
841 "slug": "about",
842 "body": "# About\n\nContent",
843 "in_nav": true,
844 "sort_key": 40,
845 "is_link": false
846 }],
847 "config": {}
848 }"##;
849
850 let manifest: InputManifest = serde_json::from_str(manifest_json).unwrap();
851
852 assert_eq!(manifest.navigation.len(), 1);
853 assert_eq!(manifest.navigation[0].title, "Album");
854 assert_eq!(manifest.albums.len(), 1);
855 assert_eq!(manifest.albums[0].title, "Album");
856 assert_eq!(
857 manifest.albums[0].description,
858 Some("A test album".to_string())
859 );
860 assert_eq!(manifest.albums[0].images.len(), 1);
861 assert_eq!(manifest.pages.len(), 1);
862 assert_eq!(manifest.pages[0].title, "About");
863 }
864
865 #[test]
866 fn parse_manifest_without_pages() {
867 let manifest_json = r##"{
868 "navigation": [],
869 "albums": [],
870 "config": {}
871 }"##;
872
873 let manifest: InputManifest = serde_json::from_str(manifest_json).unwrap();
874 assert!(manifest.pages.is_empty());
875 }
876
877 #[test]
878 fn parse_nav_item_with_children() {
879 let json = r#"{
880 "title": "Travel",
881 "path": "020-travel",
882 "children": [
883 {"title": "Japan", "path": "020-travel/010-japan", "children": []},
884 {"title": "Italy", "path": "020-travel/020-italy", "children": []}
885 ]
886 }"#;
887
888 let item: NavItem = serde_json::from_str(json).unwrap();
889 assert_eq!(item.title, "Travel");
890 assert_eq!(item.children.len(), 2);
891 assert_eq!(item.children[0].title, "Japan");
892 }
893
894 use crate::imaging::Dimensions;
899 use crate::imaging::backend::tests::MockBackend;
900
901 fn create_test_manifest(tmp: &Path) -> PathBuf {
902 create_test_manifest_with_config(tmp, "{}")
903 }
904
905 fn create_test_manifest_with_config(tmp: &Path, album_config_json: &str) -> PathBuf {
906 let manifest = format!(
907 r##"{{
908 "navigation": [],
909 "albums": [{{
910 "path": "test-album",
911 "title": "Test Album",
912 "description": null,
913 "preview_image": "test-album/001-test.jpg",
914 "images": [{{
915 "number": 1,
916 "source_path": "test-album/001-test.jpg",
917 "filename": "001-test.jpg"
918 }}],
919 "in_nav": true,
920 "config": {album_config}
921 }}],
922 "config": {{}}
923 }}"##,
924 album_config = album_config_json,
925 );
926
927 let manifest_path = tmp.join("manifest.json");
928 fs::write(&manifest_path, manifest).unwrap();
929 manifest_path
930 }
931
932 fn create_dummy_source(path: &Path) {
933 fs::create_dir_all(path.parent().unwrap()).unwrap();
934 fs::write(path, "").unwrap();
936 }
937
938 #[test]
939 fn process_with_mock_generates_correct_outputs() {
940 let tmp = TempDir::new().unwrap();
941 let source_dir = tmp.path().join("source");
942 let output_dir = tmp.path().join("output");
943
944 let image_path = source_dir.join("test-album/001-test.jpg");
946 create_dummy_source(&image_path);
947
948 let manifest_path =
950 create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [100, 150]}}"#);
951
952 let backend = MockBackend::with_dimensions(vec![Dimensions {
954 width: 200,
955 height: 250,
956 }]);
957
958 let result = process_with_backend(
959 &backend,
960 &manifest_path,
961 &source_dir,
962 &output_dir,
963 false,
964 None,
965 )
966 .unwrap();
967
968 assert_eq!(result.manifest.albums.len(), 1);
970 assert_eq!(result.manifest.albums[0].images.len(), 1);
971
972 let image = &result.manifest.albums[0].images[0];
973 assert_eq!(image.dimensions, (200, 250));
974 assert!(!image.generated.is_empty());
975 assert!(!image.thumbnail.is_empty());
976 }
977
978 #[test]
979 fn process_with_mock_records_correct_operations() {
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 image_path = source_dir.join("test-album/001-test.jpg");
985 create_dummy_source(&image_path);
986
987 let manifest_path = create_test_manifest_with_config(
989 tmp.path(),
990 r#"{"images": {"sizes": [800, 1400], "quality": 85}}"#,
991 );
992
993 let backend = MockBackend::with_dimensions(vec![Dimensions {
995 width: 2000,
996 height: 1500,
997 }]);
998
999 process_with_backend(
1000 &backend,
1001 &manifest_path,
1002 &source_dir,
1003 &output_dir,
1004 false,
1005 None,
1006 )
1007 .unwrap();
1008
1009 use crate::imaging::backend::tests::RecordedOp;
1010 let ops = backend.get_operations();
1011
1012 assert_eq!(ops.len(), 5);
1014
1015 assert!(matches!(&ops[0], RecordedOp::Identify(_)));
1017
1018 assert!(matches!(&ops[1], RecordedOp::ReadMetadata(_)));
1020
1021 for op in &ops[2..4] {
1023 assert!(matches!(op, RecordedOp::Resize { quality: 85, .. }));
1024 }
1025
1026 assert!(matches!(&ops[4], RecordedOp::Thumbnail { .. }));
1028 }
1029
1030 #[test]
1031 fn process_with_mock_skips_larger_sizes() {
1032 let tmp = TempDir::new().unwrap();
1033 let source_dir = tmp.path().join("source");
1034 let output_dir = tmp.path().join("output");
1035
1036 let image_path = source_dir.join("test-album/001-test.jpg");
1037 create_dummy_source(&image_path);
1038
1039 let manifest_path = create_test_manifest_with_config(
1041 tmp.path(),
1042 r#"{"images": {"sizes": [800, 1400, 2080]}}"#,
1043 );
1044
1045 let backend = MockBackend::with_dimensions(vec![Dimensions {
1047 width: 500,
1048 height: 400,
1049 }]);
1050
1051 let result = process_with_backend(
1052 &backend,
1053 &manifest_path,
1054 &source_dir,
1055 &output_dir,
1056 false,
1057 None,
1058 )
1059 .unwrap();
1060
1061 let image = &result.manifest.albums[0].images[0];
1063 assert_eq!(image.generated.len(), 1);
1064 assert!(image.generated.contains_key("500"));
1065 }
1066
1067 #[test]
1068 fn full_index_thumbnail_cache_does_not_collide_with_regular_thumbnail() {
1069 let tmp = TempDir::new().unwrap();
1079 let source_dir = tmp.path().join("source");
1080 let output_dir = tmp.path().join("output");
1081
1082 let image_path = source_dir.join("test-album/001-test.jpg");
1083 create_dummy_source(&image_path);
1084
1085 let manifest = r##"{
1088 "navigation": [],
1089 "albums": [{
1090 "path": "test-album",
1091 "title": "Test Album",
1092 "description": null,
1093 "preview_image": "test-album/001-test.jpg",
1094 "images": [{
1095 "number": 1,
1096 "source_path": "test-album/001-test.jpg",
1097 "filename": "001-test.jpg"
1098 }],
1099 "in_nav": true,
1100 "config": {
1101 "full_index": {"generates": true}
1102 }
1103 }],
1104 "config": {
1105 "full_index": {"generates": true}
1106 }
1107 }"##;
1108 let manifest_path = tmp.path().join("manifest.json");
1109 fs::write(&manifest_path, manifest).unwrap();
1110
1111 let backend = MockBackend::with_dimensions(vec![Dimensions {
1112 width: 2000,
1113 height: 1500,
1114 }]);
1115
1116 process_with_backend(
1117 &backend,
1118 &manifest_path,
1119 &source_dir,
1120 &output_dir,
1121 true,
1122 None,
1123 )
1124 .unwrap();
1125
1126 let cache_manifest = cache::CacheManifest::load(&output_dir);
1129 let paths: Vec<&String> = cache_manifest.entries.keys().collect();
1130
1131 let has_regular = paths.iter().any(|p| p.ends_with("001-test-thumb.avif"));
1132 let has_fi = paths.iter().any(|p| p.ends_with("001-test-fi-thumb.avif"));
1133
1134 assert!(
1135 has_regular,
1136 "regular thumbnail missing from cache manifest; entries: {:?}",
1137 paths
1138 );
1139 assert!(
1140 has_fi,
1141 "full-index thumbnail missing from cache manifest; entries: {:?}",
1142 paths
1143 );
1144 }
1145
1146 #[test]
1147 fn process_source_not_found_error() {
1148 let tmp = TempDir::new().unwrap();
1149 let source_dir = tmp.path().join("source");
1150 let output_dir = tmp.path().join("output");
1151
1152 let manifest_path = create_test_manifest(tmp.path());
1154 let backend = MockBackend::new();
1155
1156 let result = process_with_backend(
1157 &backend,
1158 &manifest_path,
1159 &source_dir,
1160 &output_dir,
1161 false,
1162 None,
1163 );
1164
1165 assert!(matches!(result, Err(ProcessError::SourceNotFound(_))));
1166 }
1167
1168 fn run_cached(
1174 source_dir: &Path,
1175 output_dir: &Path,
1176 manifest_path: &Path,
1177 dims: Vec<Dimensions>,
1178 ) -> (Vec<crate::imaging::backend::tests::RecordedOp>, CacheStats) {
1179 let backend = MockBackend::with_dimensions(dims);
1180 let result =
1181 process_with_backend(&backend, manifest_path, source_dir, output_dir, true, None)
1182 .unwrap();
1183 (backend.get_operations(), result.cache_stats)
1184 }
1185
1186 #[test]
1187 fn cache_second_run_skips_all_encoding() {
1188 let tmp = TempDir::new().unwrap();
1189 let source_dir = tmp.path().join("source");
1190 let output_dir = tmp.path().join("output");
1191
1192 let image_path = source_dir.join("test-album/001-test.jpg");
1193 create_dummy_source(&image_path);
1194
1195 let manifest_path = create_test_manifest_with_config(
1196 tmp.path(),
1197 r#"{"images": {"sizes": [800, 1400], "quality": 85}}"#,
1198 );
1199
1200 let (_ops1, stats1) = run_cached(
1202 &source_dir,
1203 &output_dir,
1204 &manifest_path,
1205 vec![Dimensions {
1206 width: 2000,
1207 height: 1500,
1208 }],
1209 );
1210
1211 for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1214 let path = output_dir.join(entry);
1215 fs::create_dir_all(path.parent().unwrap()).unwrap();
1216 fs::write(&path, "fake avif").unwrap();
1217 }
1218
1219 let (ops2, stats2) = run_cached(
1221 &source_dir,
1222 &output_dir,
1223 &manifest_path,
1224 vec![Dimensions {
1225 width: 2000,
1226 height: 1500,
1227 }],
1228 );
1229
1230 assert_eq!(stats1.misses, 3);
1232 assert_eq!(stats1.hits, 0);
1233
1234 assert_eq!(stats2.hits, 3);
1236 assert_eq!(stats2.misses, 0);
1237
1238 use crate::imaging::backend::tests::RecordedOp;
1240 let encode_ops: Vec<_> = ops2
1241 .iter()
1242 .filter(|op| matches!(op, RecordedOp::Resize { .. } | RecordedOp::Thumbnail { .. }))
1243 .collect();
1244 assert_eq!(encode_ops.len(), 0);
1245 }
1246
1247 #[test]
1248 fn cache_invalidated_when_source_changes() {
1249 let tmp = TempDir::new().unwrap();
1250 let source_dir = tmp.path().join("source");
1251 let output_dir = tmp.path().join("output");
1252
1253 let image_path = source_dir.join("test-album/001-test.jpg");
1254 create_dummy_source(&image_path);
1255
1256 let manifest_path =
1257 create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [800]}}"#);
1258
1259 let (_ops1, stats1) = run_cached(
1261 &source_dir,
1262 &output_dir,
1263 &manifest_path,
1264 vec![Dimensions {
1265 width: 2000,
1266 height: 1500,
1267 }],
1268 );
1269 assert_eq!(stats1.misses, 2); for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1273 let path = output_dir.join(entry);
1274 fs::create_dir_all(path.parent().unwrap()).unwrap();
1275 fs::write(&path, "fake").unwrap();
1276 }
1277
1278 fs::write(&image_path, "different content").unwrap();
1280
1281 let (_ops2, stats2) = run_cached(
1283 &source_dir,
1284 &output_dir,
1285 &manifest_path,
1286 vec![Dimensions {
1287 width: 2000,
1288 height: 1500,
1289 }],
1290 );
1291 assert_eq!(stats2.misses, 2);
1292 assert_eq!(stats2.hits, 0);
1293 }
1294
1295 #[test]
1296 fn cache_invalidated_when_config_changes() {
1297 let tmp = TempDir::new().unwrap();
1298 let source_dir = tmp.path().join("source");
1299 let output_dir = tmp.path().join("output");
1300
1301 let image_path = source_dir.join("test-album/001-test.jpg");
1302 create_dummy_source(&image_path);
1303
1304 let manifest_path = create_test_manifest_with_config(
1306 tmp.path(),
1307 r#"{"images": {"sizes": [800], "quality": 85}}"#,
1308 );
1309 let (_ops1, stats1) = run_cached(
1310 &source_dir,
1311 &output_dir,
1312 &manifest_path,
1313 vec![Dimensions {
1314 width: 2000,
1315 height: 1500,
1316 }],
1317 );
1318 assert_eq!(stats1.misses, 2);
1319
1320 for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1322 let path = output_dir.join(entry);
1323 fs::create_dir_all(path.parent().unwrap()).unwrap();
1324 fs::write(&path, "fake").unwrap();
1325 }
1326
1327 let manifest_path = create_test_manifest_with_config(
1329 tmp.path(),
1330 r#"{"images": {"sizes": [800], "quality": 90}}"#,
1331 );
1332 let (_ops2, stats2) = run_cached(
1333 &source_dir,
1334 &output_dir,
1335 &manifest_path,
1336 vec![Dimensions {
1337 width: 2000,
1338 height: 1500,
1339 }],
1340 );
1341 assert_eq!(stats2.misses, 2);
1342 assert_eq!(stats2.hits, 0);
1343 }
1344
1345 #[test]
1346 fn no_cache_flag_forces_full_reprocess() {
1347 let tmp = TempDir::new().unwrap();
1348 let source_dir = tmp.path().join("source");
1349 let output_dir = tmp.path().join("output");
1350
1351 let image_path = source_dir.join("test-album/001-test.jpg");
1352 create_dummy_source(&image_path);
1353
1354 let manifest_path =
1355 create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [800]}}"#);
1356
1357 let (_ops1, _stats1) = run_cached(
1359 &source_dir,
1360 &output_dir,
1361 &manifest_path,
1362 vec![Dimensions {
1363 width: 2000,
1364 height: 1500,
1365 }],
1366 );
1367
1368 for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1370 let path = output_dir.join(entry);
1371 fs::create_dir_all(path.parent().unwrap()).unwrap();
1372 fs::write(&path, "fake").unwrap();
1373 }
1374
1375 let backend = MockBackend::with_dimensions(vec![Dimensions {
1377 width: 2000,
1378 height: 1500,
1379 }]);
1380 let result = process_with_backend(
1381 &backend,
1382 &manifest_path,
1383 &source_dir,
1384 &output_dir,
1385 false,
1386 None,
1387 )
1388 .unwrap();
1389
1390 assert_eq!(result.cache_stats.misses, 2);
1392 assert_eq!(result.cache_stats.hits, 0);
1393 }
1394
1395 #[test]
1396 fn cache_hit_after_album_rename() {
1397 let tmp = TempDir::new().unwrap();
1398 let source_dir = tmp.path().join("source");
1399 let output_dir = tmp.path().join("output");
1400
1401 let image_path = source_dir.join("test-album/001-test.jpg");
1402 create_dummy_source(&image_path);
1403
1404 let manifest_path =
1406 create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [800]}}"#);
1407
1408 let (_ops1, stats1) = run_cached(
1409 &source_dir,
1410 &output_dir,
1411 &manifest_path,
1412 vec![Dimensions {
1413 width: 2000,
1414 height: 1500,
1415 }],
1416 );
1417 assert_eq!(stats1.misses, 2); assert_eq!(stats1.hits, 0);
1419
1420 for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1422 let path = output_dir.join(entry);
1423 fs::create_dir_all(path.parent().unwrap()).unwrap();
1424 fs::write(&path, "fake avif").unwrap();
1425 }
1426
1427 let manifest2 = r##"{
1429 "navigation": [],
1430 "albums": [{
1431 "path": "renamed-album",
1432 "title": "Renamed Album",
1433 "description": null,
1434 "preview_image": "test-album/001-test.jpg",
1435 "images": [{
1436 "number": 1,
1437 "source_path": "test-album/001-test.jpg",
1438 "filename": "001-test.jpg"
1439 }],
1440 "in_nav": true,
1441 "config": {"images": {"sizes": [800]}}
1442 }],
1443 "config": {}
1444 }"##;
1445 let manifest_path2 = tmp.path().join("manifest2.json");
1446 fs::write(&manifest_path2, manifest2).unwrap();
1447
1448 let backend = MockBackend::with_dimensions(vec![Dimensions {
1449 width: 2000,
1450 height: 1500,
1451 }]);
1452 let result = process_with_backend(
1453 &backend,
1454 &manifest_path2,
1455 &source_dir,
1456 &output_dir,
1457 true,
1458 None,
1459 )
1460 .unwrap();
1461
1462 assert_eq!(result.cache_stats.copies, 2); assert_eq!(result.cache_stats.misses, 0);
1465 assert_eq!(result.cache_stats.hits, 0);
1466
1467 assert!(output_dir.join("renamed-album/001-test-800.avif").exists());
1469 assert!(
1470 output_dir
1471 .join("renamed-album/001-test-thumb.avif")
1472 .exists()
1473 );
1474
1475 let manifest = cache::CacheManifest::load(&output_dir);
1477 assert!(
1478 !manifest
1479 .entries
1480 .contains_key("test-album/001-test-800.avif")
1481 );
1482 assert!(
1483 !manifest
1484 .entries
1485 .contains_key("test-album/001-test-thumb.avif")
1486 );
1487 assert!(
1488 manifest
1489 .entries
1490 .contains_key("renamed-album/001-test-800.avif")
1491 );
1492 assert!(
1493 manifest
1494 .entries
1495 .contains_key("renamed-album/001-test-thumb.avif")
1496 );
1497 }
1498}