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 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/// Configuration for image processing
66#[derive(Debug, Clone)]
67pub struct ProcessConfig {
68    pub sizes: Vec<u32>,
69    pub quality: u32,
70    pub thumbnail_aspect: (u32, u32), // width, height
71    pub thumbnail_size: u32,          // size on the short edge
72}
73
74impl ProcessConfig {
75    /// Build a ProcessConfig from SiteConfig values.
76    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/// Input manifest (from scan stage)
94#[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/// Output manifest (after processing)
132#[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    /// Original dimensions (width, height)
168    pub dimensions: (u32, u32),
169    /// Generated responsive images: { "800": { "avif": "path" }, ... }
170    pub generated: std::collections::BTreeMap<String, GeneratedVariant>,
171    /// Thumbnail path
172    pub thumbnail: String,
173}
174
175#[derive(Debug, Serialize)]
176pub struct GeneratedVariant {
177    pub avif: String,
178    pub width: u32,
179    pub height: u32,
180}
181
182/// Process result containing the output manifest and cache statistics.
183pub struct ProcessResult {
184    pub manifest: OutputManifest,
185    pub cache_stats: CacheStats,
186}
187
188/// Cache outcome for a single processed variant (for progress reporting).
189#[derive(Debug, Clone)]
190pub enum VariantStatus {
191    /// Existing cached file was reused in place.
192    Cached,
193    /// Cached content found at a different path and copied.
194    Copied,
195    /// No cache entry — image was encoded from scratch.
196    Encoded,
197}
198
199impl From<&CacheLookup> for VariantStatus {
200    fn from(lookup: &CacheLookup) -> Self {
201        match lookup {
202            CacheLookup::ExactHit => VariantStatus::Cached,
203            CacheLookup::Copied => VariantStatus::Copied,
204            CacheLookup::Miss => VariantStatus::Encoded,
205        }
206    }
207}
208
209/// Information about a single processed variant (for progress reporting).
210#[derive(Debug, Clone)]
211pub struct VariantInfo {
212    /// Display label (e.g., "800px", "thumbnail").
213    pub label: String,
214    /// Whether this variant was cached, copied, or encoded.
215    pub status: VariantStatus,
216}
217
218/// Progress events emitted during image processing.
219///
220/// Sent through an optional channel so callers can display progress
221/// as images complete, without the process module touching stdout.
222#[derive(Debug, Clone)]
223pub enum ProcessEvent {
224    /// An album is about to be processed.
225    AlbumStarted { title: String, image_count: usize },
226    /// A single image finished processing (or served from cache).
227    ImageProcessed {
228        /// 1-based positional index within the album.
229        index: usize,
230        /// Title if the image has one (from IPTC or filename). `None` for
231        /// untitled images like `38.avif` — the output formatter shows
232        /// the filename instead.
233        title: Option<String>,
234        /// Relative source path (e.g., "010-Landscapes/001-dawn.jpg").
235        source_path: String,
236        /// Per-variant cache/encode status.
237        variants: Vec<VariantInfo>,
238    },
239    /// Stale cache entries were pruned after processing.
240    CachePruned { removed: u32 },
241}
242
243pub fn process(
244    manifest_path: &Path,
245    source_root: &Path,
246    output_dir: &Path,
247    use_cache: bool,
248    progress: Option<Sender<ProcessEvent>>,
249) -> Result<ProcessResult, ProcessError> {
250    let backend = RustBackend::new();
251    process_with_backend(
252        &backend,
253        manifest_path,
254        source_root,
255        output_dir,
256        use_cache,
257        progress,
258    )
259}
260
261/// Process images using a specific backend (allows testing with mock).
262pub fn process_with_backend(
263    backend: &impl ImageBackend,
264    manifest_path: &Path,
265    source_root: &Path,
266    output_dir: &Path,
267    use_cache: bool,
268    progress: Option<Sender<ProcessEvent>>,
269) -> Result<ProcessResult, ProcessError> {
270    let manifest_content = std::fs::read_to_string(manifest_path)?;
271    let input: InputManifest = serde_json::from_str(&manifest_content)?;
272
273    std::fs::create_dir_all(output_dir)?;
274
275    let cache = Mutex::new(if use_cache {
276        CacheManifest::load(output_dir)
277    } else {
278        CacheManifest::empty()
279    });
280    let stats = Mutex::new(CacheStats::default());
281
282    let mut output_albums = Vec::new();
283
284    for album in &input.albums {
285        if let Some(ref tx) = progress {
286            tx.send(ProcessEvent::AlbumStarted {
287                title: album.title.clone(),
288                image_count: album.images.len(),
289            })
290            .ok();
291        }
292
293        // Per-album config from the resolved config chain
294        let album_process = ProcessConfig::from_site_config(&album.config);
295
296        let responsive_config = ResponsiveConfig {
297            sizes: album_process.sizes.clone(),
298            quality: Quality::new(album_process.quality),
299        };
300
301        let thumbnail_config = ThumbnailConfig {
302            aspect: album_process.thumbnail_aspect,
303            short_edge: album_process.thumbnail_size,
304            quality: Quality::new(album_process.quality),
305            sharpening: Some(Sharpening::light()),
306        };
307        let album_output_dir = output_dir.join(&album.path);
308        std::fs::create_dir_all(&album_output_dir)?;
309
310        // Process images in parallel (rayon thread pool sized by config)
311        let processed_images: Result<Vec<_>, ProcessError> = album
312            .images
313            .par_iter()
314            .enumerate()
315            .map(|(idx, image)| {
316                let source_path = source_root.join(&image.source_path);
317                if !source_path.exists() {
318                    return Err(ProcessError::SourceNotFound(source_path));
319                }
320
321                let dimensions = get_dimensions(backend, &source_path)?;
322
323                // Read embedded IPTC metadata and merge with scan-phase values.
324                // This always runs so metadata changes are never stale.
325                let exif = backend.read_metadata(&source_path)?;
326                let title = metadata::resolve(&[exif.title.as_deref(), image.title.as_deref()]);
327                let description =
328                    metadata::resolve(&[image.description.as_deref(), exif.description.as_deref()]);
329                let slug = if exif.title.is_some() && title.is_some() {
330                    metadata::sanitize_slug(title.as_deref().unwrap())
331                } else {
332                    image.slug.clone()
333                };
334
335                let stem = Path::new(&image.filename)
336                    .file_stem()
337                    .unwrap()
338                    .to_str()
339                    .unwrap();
340
341                // Compute source hash once, shared across all variants
342                let source_hash = cache::hash_file(&source_path)?;
343                let ctx = CacheContext {
344                    source_hash: &source_hash,
345                    cache: &cache,
346                    stats: &stats,
347                    cache_root: output_dir,
348                };
349
350                let (raw_variants, responsive_statuses) = create_responsive_images_cached(
351                    backend,
352                    &source_path,
353                    &album_output_dir,
354                    stem,
355                    dimensions,
356                    &responsive_config,
357                    &ctx,
358                )?;
359
360                let (thumbnail_path, thumb_status) = create_thumbnail_cached(
361                    backend,
362                    &source_path,
363                    &album_output_dir,
364                    stem,
365                    &thumbnail_config,
366                    &ctx,
367                )?;
368
369                // Build variant infos for progress event (before consuming raw_variants)
370                let variant_infos: Vec<VariantInfo> = if progress.is_some() {
371                    let mut infos: Vec<VariantInfo> = raw_variants
372                        .iter()
373                        .zip(&responsive_statuses)
374                        .map(|(v, status)| VariantInfo {
375                            label: format!("{}px", v.target_size),
376                            status: status.clone(),
377                        })
378                        .collect();
379                    infos.push(VariantInfo {
380                        label: "thumbnail".to_string(),
381                        status: thumb_status,
382                    });
383                    infos
384                } else {
385                    Vec::new()
386                };
387
388                let generated: std::collections::BTreeMap<String, GeneratedVariant> = raw_variants
389                    .into_iter()
390                    .map(|v| {
391                        (
392                            v.target_size.to_string(),
393                            GeneratedVariant {
394                                avif: v.avif_path,
395                                width: v.width,
396                                height: v.height,
397                            },
398                        )
399                    })
400                    .collect();
401
402                if let Some(ref tx) = progress {
403                    tx.send(ProcessEvent::ImageProcessed {
404                        index: idx + 1,
405                        title: title.clone(),
406                        source_path: image.source_path.clone(),
407                        variants: variant_infos,
408                    })
409                    .ok();
410                }
411
412                Ok((
413                    image,
414                    dimensions,
415                    generated,
416                    thumbnail_path,
417                    title,
418                    description,
419                    slug,
420                ))
421            })
422            .collect();
423        let processed_images = processed_images?;
424
425        // Build output images (preserving order)
426        let mut output_images: Vec<OutputImage> = processed_images
427            .into_iter()
428            .map(
429                |(image, dimensions, generated, thumbnail_path, title, description, slug)| {
430                    OutputImage {
431                        number: image.number,
432                        source_path: image.source_path.clone(),
433                        slug,
434                        title,
435                        description,
436                        dimensions,
437                        generated,
438                        thumbnail: thumbnail_path,
439                    }
440                },
441            )
442            .collect();
443
444        // Sort by number to ensure consistent ordering
445        output_images.sort_by_key(|img| img.number);
446
447        // Find album thumbnail: the preview_image is always in the image list.
448        let album_thumbnail = output_images
449            .iter()
450            .find(|img| img.source_path == album.preview_image)
451            .expect("preview_image must be in the image list")
452            .thumbnail
453            .clone();
454
455        output_albums.push(OutputAlbum {
456            path: album.path.clone(),
457            title: album.title.clone(),
458            description: album.description.clone(),
459            preview_image: album.preview_image.clone(),
460            thumbnail: album_thumbnail,
461            images: output_images,
462            in_nav: album.in_nav,
463            config: album.config.clone(),
464            support_files: album.support_files.clone(),
465        });
466    }
467
468    // Collect all output paths that are live in this build
469    let live_paths: std::collections::HashSet<String> = output_albums
470        .iter()
471        .flat_map(|album| {
472            let image_paths = album.images.iter().flat_map(|img| {
473                let mut paths: Vec<String> =
474                    img.generated.values().map(|v| v.avif.clone()).collect();
475                paths.push(img.thumbnail.clone());
476                paths
477            });
478            std::iter::once(album.thumbnail.clone()).chain(image_paths)
479        })
480        .collect();
481
482    let mut final_cache = cache.into_inner().unwrap();
483    let pruned = final_cache.prune(&live_paths, output_dir);
484    let final_stats = stats.into_inner().unwrap();
485    final_cache.save(output_dir)?;
486
487    if let Some(ref tx) = progress
488        && pruned > 0
489    {
490        tx.send(ProcessEvent::CachePruned { removed: pruned }).ok();
491    }
492
493    Ok(ProcessResult {
494        manifest: OutputManifest {
495            navigation: input.navigation,
496            albums: output_albums,
497            pages: input.pages,
498            description: input.description,
499            config: input.config,
500        },
501        cache_stats: final_stats,
502    })
503}
504
505/// Shared cache state passed to per-image encoding functions.
506struct CacheContext<'a> {
507    source_hash: &'a str,
508    cache: &'a Mutex<CacheManifest>,
509    stats: &'a Mutex<CacheStats>,
510    cache_root: &'a Path,
511}
512
513/// Result of checking the content-based cache.
514enum CacheLookup {
515    /// Same content, same path — file already in place.
516    ExactHit,
517    /// Same content at a different path — file copied to new location.
518    Copied,
519    /// No cached file available — caller must encode.
520    Miss,
521}
522
523/// Check the cache and, if the content exists at a different path, copy it.
524///
525/// Returns `ExactHit` when the cached file is already at `expected_path`,
526/// `Copied` when a file with matching content was found elsewhere and
527/// copied to `expected_path`, or `Miss` when no cached version exists
528/// (or the copy failed).
529///
530/// The cache mutex is held across the entire find+copy+insert sequence to
531/// prevent a race where two threads processing swapped images clobber each
532/// other's source files (Thread A copies over B's file before B reads it).
533fn check_cache_and_copy(
534    expected_path: &str,
535    source_hash: &str,
536    params_hash: &str,
537    ctx: &CacheContext<'_>,
538) -> CacheLookup {
539    let mut cache = ctx.cache.lock().unwrap();
540    let cached_path = cache.find_cached(source_hash, params_hash, ctx.cache_root);
541
542    match cached_path {
543        Some(ref stored) if stored == expected_path => CacheLookup::ExactHit,
544        Some(ref stored) => {
545            let old_file = ctx.cache_root.join(stored);
546            let new_file = ctx.cache_root.join(expected_path);
547            if let Some(parent) = new_file.parent() {
548                let _ = std::fs::create_dir_all(parent);
549            }
550            match std::fs::copy(&old_file, &new_file) {
551                Ok(_) => {
552                    cache.insert(
553                        expected_path.to_string(),
554                        source_hash.to_string(),
555                        params_hash.to_string(),
556                    );
557                    CacheLookup::Copied
558                }
559                Err(_) => CacheLookup::Miss,
560            }
561        }
562        None => CacheLookup::Miss,
563    }
564}
565
566/// Create responsive images with cache awareness.
567///
568/// For each variant, checks the cache before encoding. On a cache hit the
569/// existing output file is reused (or copied from its old location if the
570/// album was renamed) and no backend call is made.
571fn create_responsive_images_cached(
572    backend: &impl ImageBackend,
573    source: &Path,
574    output_dir: &Path,
575    filename_stem: &str,
576    original_dims: (u32, u32),
577    config: &ResponsiveConfig,
578    ctx: &CacheContext<'_>,
579) -> Result<
580    (
581        Vec<crate::imaging::operations::GeneratedVariant>,
582        Vec<VariantStatus>,
583    ),
584    ProcessError,
585> {
586    use crate::imaging::calculations::calculate_responsive_sizes;
587
588    let sizes = calculate_responsive_sizes(original_dims, &config.sizes);
589    let mut variants = Vec::new();
590    let mut statuses = Vec::new();
591
592    let relative_dir = output_dir
593        .strip_prefix(ctx.cache_root)
594        .unwrap()
595        .to_str()
596        .unwrap();
597
598    for size in sizes {
599        let avif_name = format!("{}-{}.avif", filename_stem, size.target);
600        let relative_path = format!("{}/{}", relative_dir, avif_name);
601        let params_hash = cache::hash_responsive_params(size.target, config.quality.value());
602
603        let lookup = check_cache_and_copy(&relative_path, ctx.source_hash, &params_hash, ctx);
604        match &lookup {
605            CacheLookup::ExactHit => {
606                ctx.stats.lock().unwrap().hit();
607            }
608            CacheLookup::Copied => {
609                ctx.stats.lock().unwrap().copy();
610            }
611            CacheLookup::Miss => {
612                let avif_path = output_dir.join(&avif_name);
613                backend.resize(&crate::imaging::params::ResizeParams {
614                    source: source.to_path_buf(),
615                    output: avif_path,
616                    width: size.width,
617                    height: size.height,
618                    quality: config.quality,
619                })?;
620                ctx.cache.lock().unwrap().insert(
621                    relative_path.clone(),
622                    ctx.source_hash.to_string(),
623                    params_hash,
624                );
625                ctx.stats.lock().unwrap().miss();
626            }
627        }
628
629        statuses.push(VariantStatus::from(&lookup));
630        variants.push(crate::imaging::operations::GeneratedVariant {
631            target_size: size.target,
632            avif_path: relative_path,
633            width: size.width,
634            height: size.height,
635        });
636    }
637
638    Ok((variants, statuses))
639}
640
641/// Create a thumbnail with cache awareness.
642fn create_thumbnail_cached(
643    backend: &impl ImageBackend,
644    source: &Path,
645    output_dir: &Path,
646    filename_stem: &str,
647    config: &ThumbnailConfig,
648    ctx: &CacheContext<'_>,
649) -> Result<(String, VariantStatus), ProcessError> {
650    let thumb_name = format!("{}-thumb.avif", filename_stem);
651    let relative_dir = output_dir
652        .strip_prefix(ctx.cache_root)
653        .unwrap()
654        .to_str()
655        .unwrap();
656    let relative_path = format!("{}/{}", relative_dir, thumb_name);
657
658    let sharpening_tuple = config.sharpening.map(|s| (s.sigma, s.threshold));
659    let params_hash = cache::hash_thumbnail_params(
660        config.aspect,
661        config.short_edge,
662        config.quality.value(),
663        sharpening_tuple,
664    );
665
666    let lookup = check_cache_and_copy(&relative_path, ctx.source_hash, &params_hash, ctx);
667    match &lookup {
668        CacheLookup::ExactHit => {
669            ctx.stats.lock().unwrap().hit();
670        }
671        CacheLookup::Copied => {
672            ctx.stats.lock().unwrap().copy();
673        }
674        CacheLookup::Miss => {
675            let thumb_path = output_dir.join(&thumb_name);
676            let params = crate::imaging::operations::plan_thumbnail(source, &thumb_path, config);
677            backend.thumbnail(&params)?;
678            ctx.cache.lock().unwrap().insert(
679                relative_path.clone(),
680                ctx.source_hash.to_string(),
681                params_hash,
682            );
683            ctx.stats.lock().unwrap().miss();
684        }
685    }
686
687    let status = VariantStatus::from(&lookup);
688    Ok((relative_path, status))
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694    use std::fs;
695    use tempfile::TempDir;
696
697    // =========================================================================
698    // ProcessConfig tests
699    // =========================================================================
700
701    #[test]
702    fn process_config_default_values() {
703        let config = ProcessConfig::default();
704
705        assert_eq!(config.sizes, vec![800, 1400, 2080]);
706        assert_eq!(config.quality, 90);
707        assert_eq!(config.thumbnail_aspect, (4, 5));
708        assert_eq!(config.thumbnail_size, 400);
709    }
710
711    #[test]
712    fn process_config_custom_values() {
713        let config = ProcessConfig {
714            sizes: vec![100, 200],
715            quality: 85,
716            thumbnail_aspect: (1, 1),
717            thumbnail_size: 150,
718        };
719
720        assert_eq!(config.sizes, vec![100, 200]);
721        assert_eq!(config.quality, 85);
722        assert_eq!(config.thumbnail_aspect, (1, 1));
723        assert_eq!(config.thumbnail_size, 150);
724    }
725
726    // =========================================================================
727    // Manifest parsing tests
728    // =========================================================================
729
730    #[test]
731    fn parse_input_manifest() {
732        let manifest_json = r##"{
733            "navigation": [
734                {"title": "Album", "path": "010-album", "children": []}
735            ],
736            "albums": [{
737                "path": "010-album",
738                "title": "Album",
739                "description": "A test album",
740                "preview_image": "010-album/001-test.jpg",
741                "images": [{
742                    "number": 1,
743                    "source_path": "010-album/001-test.jpg",
744                    "filename": "001-test.jpg"
745                }],
746                "in_nav": true,
747                "config": {}
748            }],
749            "pages": [{
750                "title": "About",
751                "link_title": "about",
752                "slug": "about",
753                "body": "# About\n\nContent",
754                "in_nav": true,
755                "sort_key": 40,
756                "is_link": false
757            }],
758            "config": {}
759        }"##;
760
761        let manifest: InputManifest = serde_json::from_str(manifest_json).unwrap();
762
763        assert_eq!(manifest.navigation.len(), 1);
764        assert_eq!(manifest.navigation[0].title, "Album");
765        assert_eq!(manifest.albums.len(), 1);
766        assert_eq!(manifest.albums[0].title, "Album");
767        assert_eq!(
768            manifest.albums[0].description,
769            Some("A test album".to_string())
770        );
771        assert_eq!(manifest.albums[0].images.len(), 1);
772        assert_eq!(manifest.pages.len(), 1);
773        assert_eq!(manifest.pages[0].title, "About");
774    }
775
776    #[test]
777    fn parse_manifest_without_pages() {
778        let manifest_json = r##"{
779            "navigation": [],
780            "albums": [],
781            "config": {}
782        }"##;
783
784        let manifest: InputManifest = serde_json::from_str(manifest_json).unwrap();
785        assert!(manifest.pages.is_empty());
786    }
787
788    #[test]
789    fn parse_nav_item_with_children() {
790        let json = r#"{
791            "title": "Travel",
792            "path": "020-travel",
793            "children": [
794                {"title": "Japan", "path": "020-travel/010-japan", "children": []},
795                {"title": "Italy", "path": "020-travel/020-italy", "children": []}
796            ]
797        }"#;
798
799        let item: NavItem = serde_json::from_str(json).unwrap();
800        assert_eq!(item.title, "Travel");
801        assert_eq!(item.children.len(), 2);
802        assert_eq!(item.children[0].title, "Japan");
803    }
804
805    // =========================================================================
806    // Process with mock backend tests
807    // =========================================================================
808
809    use crate::imaging::Dimensions;
810    use crate::imaging::backend::tests::MockBackend;
811
812    fn create_test_manifest(tmp: &Path) -> PathBuf {
813        create_test_manifest_with_config(tmp, "{}")
814    }
815
816    fn create_test_manifest_with_config(tmp: &Path, album_config_json: &str) -> PathBuf {
817        let manifest = format!(
818            r##"{{
819            "navigation": [],
820            "albums": [{{
821                "path": "test-album",
822                "title": "Test Album",
823                "description": null,
824                "preview_image": "test-album/001-test.jpg",
825                "images": [{{
826                    "number": 1,
827                    "source_path": "test-album/001-test.jpg",
828                    "filename": "001-test.jpg"
829                }}],
830                "in_nav": true,
831                "config": {album_config}
832            }}],
833            "config": {{}}
834        }}"##,
835            album_config = album_config_json,
836        );
837
838        let manifest_path = tmp.join("manifest.json");
839        fs::write(&manifest_path, manifest).unwrap();
840        manifest_path
841    }
842
843    fn create_dummy_source(path: &Path) {
844        fs::create_dir_all(path.parent().unwrap()).unwrap();
845        // Just create an empty file - the mock backend doesn't need real content
846        fs::write(path, "").unwrap();
847    }
848
849    #[test]
850    fn process_with_mock_generates_correct_outputs() {
851        let tmp = TempDir::new().unwrap();
852        let source_dir = tmp.path().join("source");
853        let output_dir = tmp.path().join("output");
854
855        // Create dummy source file
856        let image_path = source_dir.join("test-album/001-test.jpg");
857        create_dummy_source(&image_path);
858
859        // Create manifest with per-album config
860        let manifest_path =
861            create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [100, 150]}}"#);
862
863        // Create mock backend with dimensions
864        let backend = MockBackend::with_dimensions(vec![Dimensions {
865            width: 200,
866            height: 250,
867        }]);
868
869        let result = process_with_backend(
870            &backend,
871            &manifest_path,
872            &source_dir,
873            &output_dir,
874            false,
875            None,
876        )
877        .unwrap();
878
879        // Verify outputs
880        assert_eq!(result.manifest.albums.len(), 1);
881        assert_eq!(result.manifest.albums[0].images.len(), 1);
882
883        let image = &result.manifest.albums[0].images[0];
884        assert_eq!(image.dimensions, (200, 250));
885        assert!(!image.generated.is_empty());
886        assert!(!image.thumbnail.is_empty());
887    }
888
889    #[test]
890    fn process_with_mock_records_correct_operations() {
891        let tmp = TempDir::new().unwrap();
892        let source_dir = tmp.path().join("source");
893        let output_dir = tmp.path().join("output");
894
895        let image_path = source_dir.join("test-album/001-test.jpg");
896        create_dummy_source(&image_path);
897
898        // Per-album config with quality=85 and sizes=[800,1400]
899        let manifest_path = create_test_manifest_with_config(
900            tmp.path(),
901            r#"{"images": {"sizes": [800, 1400], "quality": 85}}"#,
902        );
903
904        // 2000x1500 landscape - should generate both sizes
905        let backend = MockBackend::with_dimensions(vec![Dimensions {
906            width: 2000,
907            height: 1500,
908        }]);
909
910        process_with_backend(
911            &backend,
912            &manifest_path,
913            &source_dir,
914            &output_dir,
915            false,
916            None,
917        )
918        .unwrap();
919
920        use crate::imaging::backend::tests::RecordedOp;
921        let ops = backend.get_operations();
922
923        // Should have: 1 identify + 1 read_metadata + 2 resizes (2 sizes × AVIF) + 1 thumbnail = 5 ops
924        assert_eq!(ops.len(), 5);
925
926        // First is identify
927        assert!(matches!(&ops[0], RecordedOp::Identify(_)));
928
929        // Second is read_metadata
930        assert!(matches!(&ops[1], RecordedOp::ReadMetadata(_)));
931
932        // Then resizes with correct quality
933        for op in &ops[2..4] {
934            assert!(matches!(op, RecordedOp::Resize { quality: 85, .. }));
935        }
936
937        // Last is thumbnail
938        assert!(matches!(&ops[4], RecordedOp::Thumbnail { .. }));
939    }
940
941    #[test]
942    fn process_with_mock_skips_larger_sizes() {
943        let tmp = TempDir::new().unwrap();
944        let source_dir = tmp.path().join("source");
945        let output_dir = tmp.path().join("output");
946
947        let image_path = source_dir.join("test-album/001-test.jpg");
948        create_dummy_source(&image_path);
949
950        // Per-album config with sizes larger than the source image
951        let manifest_path = create_test_manifest_with_config(
952            tmp.path(),
953            r#"{"images": {"sizes": [800, 1400, 2080]}}"#,
954        );
955
956        // 500x400 - smaller than all requested sizes
957        let backend = MockBackend::with_dimensions(vec![Dimensions {
958            width: 500,
959            height: 400,
960        }]);
961
962        let result = process_with_backend(
963            &backend,
964            &manifest_path,
965            &source_dir,
966            &output_dir,
967            false,
968            None,
969        )
970        .unwrap();
971
972        // Should only have original size
973        let image = &result.manifest.albums[0].images[0];
974        assert_eq!(image.generated.len(), 1);
975        assert!(image.generated.contains_key("500"));
976    }
977
978    #[test]
979    fn process_source_not_found_error() {
980        let tmp = TempDir::new().unwrap();
981        let source_dir = tmp.path().join("source");
982        let output_dir = tmp.path().join("output");
983
984        // Don't create the source file
985        let manifest_path = create_test_manifest(tmp.path());
986        let backend = MockBackend::new();
987
988        let result = process_with_backend(
989            &backend,
990            &manifest_path,
991            &source_dir,
992            &output_dir,
993            false,
994            None,
995        );
996
997        assert!(matches!(result, Err(ProcessError::SourceNotFound(_))));
998    }
999
1000    // =========================================================================
1001    // Cache integration tests
1002    // =========================================================================
1003
1004    /// Helper: run process with cache enabled, returning (ops_count, cache_stats).
1005    fn run_cached(
1006        source_dir: &Path,
1007        output_dir: &Path,
1008        manifest_path: &Path,
1009        dims: Vec<Dimensions>,
1010    ) -> (Vec<crate::imaging::backend::tests::RecordedOp>, CacheStats) {
1011        let backend = MockBackend::with_dimensions(dims);
1012        let result =
1013            process_with_backend(&backend, manifest_path, source_dir, output_dir, true, None)
1014                .unwrap();
1015        (backend.get_operations(), result.cache_stats)
1016    }
1017
1018    #[test]
1019    fn cache_second_run_skips_all_encoding() {
1020        let tmp = TempDir::new().unwrap();
1021        let source_dir = tmp.path().join("source");
1022        let output_dir = tmp.path().join("output");
1023
1024        let image_path = source_dir.join("test-album/001-test.jpg");
1025        create_dummy_source(&image_path);
1026
1027        let manifest_path = create_test_manifest_with_config(
1028            tmp.path(),
1029            r#"{"images": {"sizes": [800, 1400], "quality": 85}}"#,
1030        );
1031
1032        // First run: everything is a miss
1033        let (_ops1, stats1) = run_cached(
1034            &source_dir,
1035            &output_dir,
1036            &manifest_path,
1037            vec![Dimensions {
1038                width: 2000,
1039                height: 1500,
1040            }],
1041        );
1042
1043        // The mock backend doesn't write real files, so we need to create
1044        // dummy output files for the cache hit check on the second run.
1045        for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1046            let path = output_dir.join(entry);
1047            fs::create_dir_all(path.parent().unwrap()).unwrap();
1048            fs::write(&path, "fake avif").unwrap();
1049        }
1050
1051        // Second run: everything should be a cache hit
1052        let (ops2, stats2) = run_cached(
1053            &source_dir,
1054            &output_dir,
1055            &manifest_path,
1056            vec![Dimensions {
1057                width: 2000,
1058                height: 1500,
1059            }],
1060        );
1061
1062        // First run: 2 resizes + 1 thumbnail = 3 misses
1063        assert_eq!(stats1.misses, 3);
1064        assert_eq!(stats1.hits, 0);
1065
1066        // Second run: 0 resizes + 0 thumbnails encoded, all cached
1067        assert_eq!(stats2.hits, 3);
1068        assert_eq!(stats2.misses, 0);
1069
1070        // Second run should only have identify + read_metadata (no resize/thumbnail)
1071        use crate::imaging::backend::tests::RecordedOp;
1072        let encode_ops: Vec<_> = ops2
1073            .iter()
1074            .filter(|op| matches!(op, RecordedOp::Resize { .. } | RecordedOp::Thumbnail { .. }))
1075            .collect();
1076        assert_eq!(encode_ops.len(), 0);
1077    }
1078
1079    #[test]
1080    fn cache_invalidated_when_source_changes() {
1081        let tmp = TempDir::new().unwrap();
1082        let source_dir = tmp.path().join("source");
1083        let output_dir = tmp.path().join("output");
1084
1085        let image_path = source_dir.join("test-album/001-test.jpg");
1086        create_dummy_source(&image_path);
1087
1088        let manifest_path =
1089            create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [800]}}"#);
1090
1091        // First run
1092        let (_ops1, stats1) = run_cached(
1093            &source_dir,
1094            &output_dir,
1095            &manifest_path,
1096            vec![Dimensions {
1097                width: 2000,
1098                height: 1500,
1099            }],
1100        );
1101        assert_eq!(stats1.misses, 2); // 1 resize + 1 thumb
1102
1103        // Create dummy outputs
1104        for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1105            let path = output_dir.join(entry);
1106            fs::create_dir_all(path.parent().unwrap()).unwrap();
1107            fs::write(&path, "fake").unwrap();
1108        }
1109
1110        // Modify source file content (changes source_hash)
1111        fs::write(&image_path, "different content").unwrap();
1112
1113        // Second run: cache should miss because source hash changed
1114        let (_ops2, stats2) = run_cached(
1115            &source_dir,
1116            &output_dir,
1117            &manifest_path,
1118            vec![Dimensions {
1119                width: 2000,
1120                height: 1500,
1121            }],
1122        );
1123        assert_eq!(stats2.misses, 2);
1124        assert_eq!(stats2.hits, 0);
1125    }
1126
1127    #[test]
1128    fn cache_invalidated_when_config_changes() {
1129        let tmp = TempDir::new().unwrap();
1130        let source_dir = tmp.path().join("source");
1131        let output_dir = tmp.path().join("output");
1132
1133        let image_path = source_dir.join("test-album/001-test.jpg");
1134        create_dummy_source(&image_path);
1135
1136        // First run with quality=85
1137        let manifest_path = create_test_manifest_with_config(
1138            tmp.path(),
1139            r#"{"images": {"sizes": [800], "quality": 85}}"#,
1140        );
1141        let (_ops1, stats1) = run_cached(
1142            &source_dir,
1143            &output_dir,
1144            &manifest_path,
1145            vec![Dimensions {
1146                width: 2000,
1147                height: 1500,
1148            }],
1149        );
1150        assert_eq!(stats1.misses, 2);
1151
1152        // Create dummy outputs
1153        for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1154            let path = output_dir.join(entry);
1155            fs::create_dir_all(path.parent().unwrap()).unwrap();
1156            fs::write(&path, "fake").unwrap();
1157        }
1158
1159        // Second run with quality=90 — params_hash changes, cache invalidated
1160        let manifest_path = create_test_manifest_with_config(
1161            tmp.path(),
1162            r#"{"images": {"sizes": [800], "quality": 90}}"#,
1163        );
1164        let (_ops2, stats2) = run_cached(
1165            &source_dir,
1166            &output_dir,
1167            &manifest_path,
1168            vec![Dimensions {
1169                width: 2000,
1170                height: 1500,
1171            }],
1172        );
1173        assert_eq!(stats2.misses, 2);
1174        assert_eq!(stats2.hits, 0);
1175    }
1176
1177    #[test]
1178    fn no_cache_flag_forces_full_reprocess() {
1179        let tmp = TempDir::new().unwrap();
1180        let source_dir = tmp.path().join("source");
1181        let output_dir = tmp.path().join("output");
1182
1183        let image_path = source_dir.join("test-album/001-test.jpg");
1184        create_dummy_source(&image_path);
1185
1186        let manifest_path =
1187            create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [800]}}"#);
1188
1189        // First run with cache
1190        let (_ops1, _stats1) = run_cached(
1191            &source_dir,
1192            &output_dir,
1193            &manifest_path,
1194            vec![Dimensions {
1195                width: 2000,
1196                height: 1500,
1197            }],
1198        );
1199
1200        // Create dummy outputs
1201        for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1202            let path = output_dir.join(entry);
1203            fs::create_dir_all(path.parent().unwrap()).unwrap();
1204            fs::write(&path, "fake").unwrap();
1205        }
1206
1207        // Second run with use_cache=false (simulates --no-cache)
1208        let backend = MockBackend::with_dimensions(vec![Dimensions {
1209            width: 2000,
1210            height: 1500,
1211        }]);
1212        let result = process_with_backend(
1213            &backend,
1214            &manifest_path,
1215            &source_dir,
1216            &output_dir,
1217            false,
1218            None,
1219        )
1220        .unwrap();
1221
1222        // Should re-encode everything despite outputs existing
1223        assert_eq!(result.cache_stats.misses, 2);
1224        assert_eq!(result.cache_stats.hits, 0);
1225    }
1226
1227    #[test]
1228    fn cache_hit_after_album_rename() {
1229        let tmp = TempDir::new().unwrap();
1230        let source_dir = tmp.path().join("source");
1231        let output_dir = tmp.path().join("output");
1232
1233        let image_path = source_dir.join("test-album/001-test.jpg");
1234        create_dummy_source(&image_path);
1235
1236        // First run: album path is "test-album"
1237        let manifest_path =
1238            create_test_manifest_with_config(tmp.path(), r#"{"images": {"sizes": [800]}}"#);
1239
1240        let (_ops1, stats1) = run_cached(
1241            &source_dir,
1242            &output_dir,
1243            &manifest_path,
1244            vec![Dimensions {
1245                width: 2000,
1246                height: 1500,
1247            }],
1248        );
1249        assert_eq!(stats1.misses, 2); // 1 resize + 1 thumb
1250        assert_eq!(stats1.hits, 0);
1251
1252        // Create dummy output files (mock backend doesn't write real files)
1253        for entry in cache::CacheManifest::load(&output_dir).entries.keys() {
1254            let path = output_dir.join(entry);
1255            fs::create_dir_all(path.parent().unwrap()).unwrap();
1256            fs::write(&path, "fake avif").unwrap();
1257        }
1258
1259        // Second run: album renamed to "renamed-album", same source image
1260        let manifest2 = r##"{
1261            "navigation": [],
1262            "albums": [{
1263                "path": "renamed-album",
1264                "title": "Renamed Album",
1265                "description": null,
1266                "preview_image": "test-album/001-test.jpg",
1267                "images": [{
1268                    "number": 1,
1269                    "source_path": "test-album/001-test.jpg",
1270                    "filename": "001-test.jpg"
1271                }],
1272                "in_nav": true,
1273                "config": {"images": {"sizes": [800]}}
1274            }],
1275            "config": {}
1276        }"##;
1277        let manifest_path2 = tmp.path().join("manifest2.json");
1278        fs::write(&manifest_path2, manifest2).unwrap();
1279
1280        let backend = MockBackend::with_dimensions(vec![Dimensions {
1281            width: 2000,
1282            height: 1500,
1283        }]);
1284        let result = process_with_backend(
1285            &backend,
1286            &manifest_path2,
1287            &source_dir,
1288            &output_dir,
1289            true,
1290            None,
1291        )
1292        .unwrap();
1293
1294        // Should be copies (not re-encoded) since content is identical
1295        assert_eq!(result.cache_stats.copies, 2); // 1 resize + 1 thumb copied
1296        assert_eq!(result.cache_stats.misses, 0);
1297        assert_eq!(result.cache_stats.hits, 0);
1298
1299        // Verify copied files exist at the new path
1300        assert!(output_dir.join("renamed-album/001-test-800.avif").exists());
1301        assert!(
1302            output_dir
1303                .join("renamed-album/001-test-thumb.avif")
1304                .exists()
1305        );
1306
1307        // Verify stale entries were cleaned up
1308        let manifest = cache::CacheManifest::load(&output_dir);
1309        assert!(
1310            !manifest
1311                .entries
1312                .contains_key("test-album/001-test-800.avif")
1313        );
1314        assert!(
1315            !manifest
1316                .entries
1317                .contains_key("test-album/001-test-thumb.avif")
1318        );
1319        assert!(
1320            manifest
1321                .entries
1322                .contains_key("renamed-album/001-test-800.avif")
1323        );
1324        assert!(
1325            manifest
1326                .entries
1327                .contains_key("renamed-album/001-test-thumb.avif")
1328        );
1329    }
1330}