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