Skip to main content

simple_gal/
process.rs

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