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