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