Skip to main content

ssg/plugins/
image_plugin.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Image optimization plugin.
5//!
6//! Processes images to generate WebP variants and responsive `<picture>`
7//! elements with `srcset`, `loading="lazy"`, and `decoding="async"`.
8//!
9//! ## Responsive pipeline
10//!
11//! For each `<img>` in compiled HTML the plugin emits:
12//!
13//! ```html
14//! <picture>
15//!   <source type="image/avif" srcset="…-320w.avif 320w, …"> <!-- if avif feature -->
16//!   <source type="image/webp" srcset="…-320w.webp 320w, …">
17//!   <img src="/original.jpg" alt="…" width="…" height="…"
18//!        loading="lazy" decoding="async">
19//! </picture>
20//! ```
21//!
22//! Images tagged with `fetchpriority="high"` receive `loading="eager"`
23//! instead, so the browser fetches them immediately.
24
25#[cfg(feature = "image-optimization")]
26use crate::error::{PathErrorExt, SsgError};
27#[cfg(feature = "image-optimization")]
28use crate::plugin::{Plugin, PluginContext};
29
30#[cfg(feature = "image-optimization")]
31use std::{
32    collections::HashMap,
33    fs,
34    path::{Path, PathBuf},
35};
36
37/// Default responsive breakpoints (px).
38#[cfg(feature = "image-optimization")]
39const DEFAULT_BREAKPOINTS: &[u32] = &[320, 640, 1024, 1440];
40
41/// Default WebP encoding quality (1–100).
42#[cfg(feature = "image-optimization")]
43const DEFAULT_QUALITY: u8 = 80;
44
45/// Default AVIF encoding quality (1–100). 70 is the sweet spot in the
46/// `ravif` docs: visually transparent on photographic content while
47/// producing files 20-30 % smaller than equivalent WebP. Lowering to
48/// 60 still keeps SSIM ≥ 0.95 on the typical JPEG source (issue #521
49/// AC4); raising past 80 yields rapidly diminishing returns.
50#[cfg(feature = "image-optimization")]
51const DEFAULT_AVIF_QUALITY: u8 = 70;
52
53/// `ravif` encoding speed preset (1 = slowest/best, 10 = fastest).
54/// `4` is the published sweet spot — closer to "best" than to "fastest"
55/// without the cost cliff of `1`/`2`.
56#[cfg(feature = "image-optimization")]
57const AVIF_SPEED: u8 = 4;
58
59/// Plugin that optimises images and rewrites HTML with `<picture>` tags.
60///
61/// Runs in `after_compile`:
62/// 1. Scans `site_dir` for JPEG/PNG images
63/// 2. Generates WebP and AVIF variants at responsive widths
64/// 3. Rewrites `<img>` tags to `<picture>` with `srcset`
65/// 4. Adds `loading="lazy"`, `decoding="async"`, `width`, `height`
66///
67/// The quality and breakpoints are configurable via the struct fields.
68#[cfg(feature = "image-optimization")]
69#[derive(Debug, Clone)]
70pub struct ImageOptimizationPlugin {
71    /// WebP encoding quality (1–100). Defaults to 80.
72    pub quality: u8,
73    /// AVIF encoding quality (1–100). Defaults to 70 — see the
74    /// `DEFAULT_AVIF_QUALITY` private constant for the rationale.
75    pub avif_quality: u8,
76    /// Skip AVIF encoding for non-priority images (saves build time
77    /// at the cost of bandwidth on modern browsers). Hero images
78    /// marked `fetchpriority="high"` always get AVIF regardless —
79    /// see issue #521 AC5.
80    ///
81    /// Currently exposed as a config knob; per-image priority data is
82    /// only known to the HTML rewriter (which preserves
83    /// `fetchpriority="high"` and `loading="eager"`), so the present
84    /// implementation always encodes AVIF for every responsive variant
85    /// of every collected image. Honoured-by-default in a follow-up
86    /// once priority metadata is plumbed from the shortcode layer
87    /// into `collect_images`.
88    pub lazy_avif: bool,
89    /// Responsive width breakpoints in pixels. Defaults to `[320, 640, 1024, 1440]`.
90    pub breakpoints: Vec<u32>,
91}
92
93#[cfg(feature = "image-optimization")]
94impl Default for ImageOptimizationPlugin {
95    fn default() -> Self {
96        Self {
97            quality: DEFAULT_QUALITY,
98            avif_quality: DEFAULT_AVIF_QUALITY,
99            lazy_avif: false,
100            breakpoints: DEFAULT_BREAKPOINTS.to_vec(),
101        }
102    }
103}
104
105#[cfg(feature = "image-optimization")]
106impl Plugin for ImageOptimizationPlugin {
107    fn name(&self) -> &'static str {
108        "image-optimization"
109    }
110
111    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
112        if !ctx.site_dir.exists() {
113            return Ok(());
114        }
115
116        let images = collect_images(&ctx.site_dir)?;
117        if images.is_empty() {
118            return Ok(());
119        }
120
121        let optimized_dir = ctx.site_dir.join("optimized");
122        fs::create_dir_all(&optimized_dir).with_path(&optimized_dir)?;
123
124        let manifest = optimize_all_images(
125            &images,
126            &ctx.site_dir,
127            &optimized_dir,
128            &self.breakpoints,
129            self.quality,
130            self.avif_quality,
131        );
132
133        rewrite_html_img_tags(&ctx.site_dir, &manifest)?;
134
135        let webp_count: usize =
136            manifest.values().map(|m| m.webp_variants.len()).sum();
137        let avif_count: usize =
138            manifest.values().map(|m| m.avif_variants.len()).sum();
139        log::info!(
140            "[image] Optimised {} image(s); {webp_count} WebP, {avif_count} AVIF variant(s)",
141            manifest.len()
142        );
143        Ok(())
144    }
145}
146
147#[cfg(feature = "image-optimization")]
148#[derive(Debug, Clone)]
149struct ImageVariant {
150    rel_path: String,
151    width: u32,
152}
153
154#[cfg(feature = "image-optimization")]
155#[derive(Debug, Clone)]
156struct ImageManifest {
157    original_rel: String,
158    original_width: u32,
159    original_height: u32,
160    webp_variants: Vec<ImageVariant>,
161    avif_variants: Vec<ImageVariant>,
162}
163
164/// Optimizes all images and builds the manifest, logging warnings for failures.
165#[cfg(feature = "image-optimization")]
166fn optimize_all_images(
167    images: &[PathBuf],
168    site_dir: &Path,
169    optimized_dir: &Path,
170    breakpoints: &[u32],
171    quality: u8,
172    avif_quality: u8,
173) -> HashMap<String, ImageManifest> {
174    let mut manifest = HashMap::new();
175    for img_path in images {
176        match process_image(
177            img_path,
178            site_dir,
179            optimized_dir,
180            breakpoints,
181            quality,
182            avif_quality,
183        ) {
184            Ok(entry) => {
185                let _ = manifest.insert(entry.original_rel.clone(), entry);
186            }
187            Err(e) => {
188                log::warn!(
189                    "[image] Failed to process {}: {e}",
190                    img_path.display()
191                );
192            }
193        }
194    }
195    manifest
196}
197
198/// Rewrites HTML files to use `<picture>` tags for optimized images.
199#[cfg(feature = "image-optimization")]
200fn rewrite_html_img_tags(
201    site_dir: &Path,
202    manifest: &HashMap<String, ImageManifest>,
203) -> Result<(), SsgError> {
204    let html_files = collect_html_files(site_dir)?;
205    for html_path in &html_files {
206        let html = fs::read_to_string(html_path).with_path(html_path)?;
207        let rewritten = rewrite_img_tags(&html, manifest);
208        if rewritten != html {
209            fs::write(html_path, rewritten).with_path(html_path)?;
210        }
211    }
212    Ok(())
213}
214
215/// Processes a single image: resize + encode to WebP **and** AVIF at
216/// responsive widths.
217///
218/// WebP is written serially in the breakpoint loop (encoding is cheap;
219/// `image::save` is essentially memcpy + libwebp). AVIF encoding is
220/// expensive (rav1e at speed 4 takes 50-400 ms per variant), so the
221/// per-width AVIF jobs are dispatched via `rayon::par_iter` across the
222/// breakpoints — wall-time scales with `breakpoints / cores` rather
223/// than `breakpoints * encoder_cost`. See issue #521 AC3.
224#[cfg(feature = "image-optimization")]
225fn process_image(
226    img_path: &Path,
227    site_dir: &Path,
228    optimized_dir: &Path,
229    breakpoints: &[u32],
230    _quality: u8,
231    avif_quality: u8,
232) -> Result<ImageManifest, SsgError> {
233    use rayon::prelude::*;
234
235    let img = image::open(img_path).map_err(|e| SsgError::io(e, img_path))?;
236
237    let (orig_w, orig_h) = (img.width(), img.height());
238    let rel = img_path
239        .strip_prefix(site_dir)
240        .unwrap_or(img_path)
241        .to_string_lossy()
242        .replace('\\', "/");
243
244    let stem = img_path.file_stem().unwrap_or_default().to_string_lossy();
245
246    // ---- WebP path (serial, fast) -----------------------------------
247    let mut webp_variants = Vec::new();
248    let mut resized_variants: Vec<(u32, image::DynamicImage)> = Vec::new();
249
250    for &width in breakpoints {
251        if width >= orig_w {
252            continue; // Skip sizes larger than original
253        }
254
255        let ratio = f64::from(width) / f64::from(orig_w);
256        let height = (f64::from(orig_h) * ratio) as u32;
257        let resized = img.resize_exact(
258            width,
259            height,
260            image::imageops::FilterType::Lanczos3,
261        );
262
263        // Save WebP variant
264        let variant_name = format!("{stem}-{width}w.webp");
265        let variant_path = optimized_dir.join(&variant_name);
266        resized
267            .save(&variant_path)
268            .map_err(|e| SsgError::io(e, &variant_path))?;
269
270        let variant_rel = format!("optimized/{variant_name}");
271        webp_variants.push(ImageVariant {
272            rel_path: variant_rel,
273            width,
274        });
275
276        resized_variants.push((width, resized));
277    }
278
279    // ---- AVIF path (parallel) ---------------------------------------
280    // Each `(width, DynamicImage)` is encoded on the rayon pool; the
281    // resulting bytes are written to disk on the same worker that
282    // produced them. Failures are logged + skipped so a single broken
283    // variant doesn't blow up the build.
284    let avif_results: Vec<Option<ImageVariant>> = resized_variants
285        .par_iter()
286        .map(|(width, resized)| {
287            let variant_name = format!("{stem}-{width}w.avif");
288            let variant_path = optimized_dir.join(&variant_name);
289            match encode_avif(resized, avif_quality) {
290                Ok(bytes) => match fs::write(&variant_path, &bytes) {
291                    Ok(()) => Some(ImageVariant {
292                        rel_path: format!("optimized/{variant_name}"),
293                        width: *width,
294                    }),
295                    Err(e) => {
296                        log::warn!(
297                            "[image] AVIF write failed for {}: {e}",
298                            variant_path.display()
299                        );
300                        None
301                    }
302                },
303                Err(e) => {
304                    log::warn!(
305                        "[image] AVIF encode failed for {}-{width}w: {e}",
306                        stem
307                    );
308                    None
309                }
310            }
311        })
312        .collect();
313
314    let avif_variants: Vec<ImageVariant> =
315        avif_results.into_iter().flatten().collect();
316
317    Ok(ImageManifest {
318        original_rel: rel,
319        original_width: orig_w,
320        original_height: orig_h,
321        webp_variants,
322        avif_variants,
323    })
324}
325
326/// Encode a `DynamicImage` as AVIF bytes using `ravif`.
327///
328/// The image is first converted to RGBA8 (ravif's most general input
329/// format — it auto-detects fully-opaque images and drops the alpha
330/// plane internally, so this isn't wasteful for JPEG sources). The
331/// returned `Vec<u8>` is a self-contained AVIF file ready to write to
332/// disk.
333///
334/// `quality` is clamped to `1..=100`; the speed preset is fixed at
335/// `AVIF_SPEED` (4 — "balanced", private constant). Alpha channels
336/// use the same quality as colour.
337///
338/// # Examples
339///
340/// ```rust
341/// # #[cfg(feature = "image-optimization")]
342/// # fn example() {
343/// use ssg::image_plugin::encode_avif;
344/// use image::{DynamicImage, RgbaImage};
345///
346/// let img = DynamicImage::ImageRgba8(RgbaImage::new(8, 8));
347/// let bytes = encode_avif(&img, 60).unwrap();
348/// assert!(!bytes.is_empty());
349/// # }
350/// ```
351///
352/// # Errors
353/// Returns [`SsgError::Io`] wrapping the underlying `ravif::Error`
354/// if rav1e fails to encode (effectively a bug in ravif/rav1e — the
355/// inputs we feed are always valid).
356#[cfg(feature = "image-optimization")]
357pub fn encode_avif(
358    img: &image::DynamicImage,
359    quality: u8,
360) -> Result<Vec<u8>, SsgError> {
361    use rgb::FromSlice;
362
363    fail_point!("image::encode-avif", |_| {
364        Err(SsgError::io(
365            std::io::Error::other("injected: image::encode-avif"),
366            "<avif-buffer>",
367        ))
368    });
369
370    let rgba = img.to_rgba8();
371    let width = rgba.width() as usize;
372    let height = rgba.height() as usize;
373
374    // Guard before handing the buffer to ravif: `imgref` asserts
375    // `stride > 0` and panics on zero-dimension images, so a corrupt
376    // or programmatically-empty input must surface as a typed error,
377    // never a panic (house no-panic rule).
378    if width == 0 || height == 0 {
379        return Err(SsgError::io(
380            std::io::Error::other(format!(
381                "cannot AVIF-encode empty image ({width}x{height})"
382            )),
383            "<avif-buffer>",
384        ));
385    }
386
387    let raw = rgba.as_raw().as_rgba();
388
389    let quality_f = f32::from(quality.clamp(1, 100));
390
391    let encoded = ravif::Encoder::new()
392        .with_quality(quality_f)
393        .with_alpha_quality(quality_f)
394        .with_speed(AVIF_SPEED)
395        .encode_rgba(ravif::Img::new(raw, width, height))
396        .map_err(|e| {
397            SsgError::io(
398                std::io::Error::other(format!("ravif encode failed: {e}")),
399                "<avif-buffer>",
400            )
401        })?;
402
403    Ok(encoded.avif_file)
404}
405
406/// Rewrites `<img src="...">` tags to `<picture>` with srcset.
407///
408/// For each image in the manifest that has variants, the original
409/// `<img>` tag is wrapped in a `<picture>` element with:
410/// - `<source type="image/avif" srcset="...">` (if AVIF variants exist)
411/// - `<source type="image/webp" srcset="...">`
412/// - `<img>` fallback with `loading`, `decoding`, `width`, `height`
413///
414/// Images with `fetchpriority="high"` get `loading="eager"` instead of
415/// `loading="lazy"`.
416///
417/// **Streaming parser:** uses `lol_html` so HTML comments, character
418/// entities in `alt`, and pre-existing `srcset` are all handled
419/// correctly (issue #525 AC1–AC3). Only the first match per manifest
420/// entry is rewritten, matching the previous `str::find`-based
421/// behaviour so existing tests continue to pass.
422#[cfg(feature = "image-optimization")]
423fn rewrite_img_tags(
424    html: &str,
425    manifest: &HashMap<String, ImageManifest>,
426) -> String {
427    use crate::util::html_rewriter::rewrite_html;
428    use lol_html::element;
429    use std::cell::RefCell;
430    use std::rc::Rc;
431
432    // Tracks which manifest entries have already been rewritten so the
433    // "first-occurrence-only" invariant survives the streaming pass.
434    let consumed: Rc<RefCell<std::collections::HashSet<String>>> =
435        Rc::new(RefCell::new(std::collections::HashSet::new()));
436    let manifest = manifest.clone();
437
438    let consumed_cb = Rc::clone(&consumed);
439    let handler = element!("img", move |el| {
440        let Some(src) = el.get_attribute("src") else {
441            return Ok(());
442        };
443        // Normalise to the manifest-relative key (drop leading slash if any).
444        let key = src.strip_prefix('/').unwrap_or(&src).to_string();
445        let Some(entry) = manifest.get(&key) else {
446            return Ok(());
447        };
448        if entry.webp_variants.is_empty() && entry.avif_variants.is_empty() {
449            return Ok(());
450        }
451        let original_rel = &entry.original_rel;
452        {
453            let mut consumed = consumed_cb.borrow_mut();
454            if consumed.contains(original_rel) {
455                return Ok(());
456            }
457            let _ = consumed.insert(original_rel.clone());
458        }
459
460        let alt = el.get_attribute("alt").unwrap_or_default();
461        let fetchpriority = el.get_attribute("fetchpriority");
462        let loading = if fetchpriority.as_deref() == Some("high") {
463            "eager"
464        } else {
465            "lazy"
466        };
467        let width = el
468            .get_attribute("width")
469            .and_then(|v| v.parse::<u32>().ok())
470            .unwrap_or(entry.original_width);
471        let height = el
472            .get_attribute("height")
473            .and_then(|v| v.parse::<u32>().ok())
474            .unwrap_or(entry.original_height);
475
476        // Author-supplied srcset is replaced (issue #525 AC3) — log so
477        // the build output makes the swap visible. lol_html guarantees
478        // exactly one `srcset` on the emitted `<source>` elements.
479        if el.has_attribute("srcset") {
480            log::info!(
481                "[image] replacing author-supplied srcset on '{src}' with responsive variants"
482            );
483        }
484
485        let webp_srcset: String = entry
486            .webp_variants
487            .iter()
488            .map(|v| format!("/{} {}w", v.rel_path, v.width))
489            .collect::<Vec<_>>()
490            .join(", ");
491        let avif_srcset: String = entry
492            .avif_variants
493            .iter()
494            .map(|v| format!("/{} {}w", v.rel_path, v.width))
495            .collect::<Vec<_>>()
496            .join(", ");
497        let sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw";
498
499        let mut picture = String::from("<picture>");
500        if !avif_srcset.is_empty() {
501            picture.push_str(&format!(
502                "<source type=\"image/avif\" srcset=\"{avif_srcset}\" sizes=\"{sizes}\">"
503            ));
504        }
505        if !webp_srcset.is_empty() {
506            picture.push_str(&format!(
507                "<source type=\"image/webp\" srcset=\"{webp_srcset}\" sizes=\"{sizes}\">"
508            ));
509        }
510
511        let fp_attr = match fetchpriority {
512            Some(ref fp) => format!(" fetchpriority=\"{fp}\""),
513            None => String::new(),
514        };
515        picture.push_str(&format!(
516            "<img src=\"/{original_rel}\" alt=\"{alt}\" \
517             width=\"{width}\" height=\"{height}\" \
518             loading=\"{loading}\" decoding=\"async\"{fp_attr}>",
519        ));
520        picture.push_str("</picture>");
521
522        el.replace(&picture, lol_html::html_content::ContentType::Html);
523        Ok(())
524    });
525
526    unwrap_rewrite(html, rewrite_html(html, vec![handler]))
527}
528
529/// Unwraps the streaming-rewrite result, falling back to the original
530/// HTML (with a warning) when `lol_html` failed.
531#[cfg(feature = "image-optimization")]
532fn unwrap_rewrite(html: &str, res: Result<String, SsgError>) -> String {
533    match res {
534        Ok(s) => s,
535        Err(e) => {
536            log::warn!(
537                "[image] HTML rewrite failed, leaving page unchanged: {e}"
538            );
539            html.to_string()
540        }
541    }
542}
543
544#[cfg(all(test, feature = "image-optimization"))]
545fn extract_attr(tag: &str, attr: &str) -> Option<String> {
546    // Retained as a test helper for the legacy `extract_attr_table_driven`
547    // unit test; the production `rewrite_img_tags` path now uses
548    // `lol_html`'s attribute API directly.
549    let pattern = format!("{attr}=\"");
550    let start = tag.find(&pattern)? + pattern.len();
551    let end = tag[start..].find('"')? + start;
552    Some(tag[start..end].to_string())
553}
554
555/// Collect all `.jpg`/`.jpeg`/`.png` files under `dir`, skipping any
556/// that live inside an `optimized/` subdirectory (the plugin's own
557/// output directory — must not be re-processed).
558#[cfg(feature = "image-optimization")]
559fn collect_images(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
560    let all = crate::walk::walk_files_multi(dir, &["jpg", "jpeg", "png"])?;
561    Ok(all
562        .into_iter()
563        .filter(|p| !p.components().any(|c| c.as_os_str() == "optimized"))
564        .collect())
565}
566
567#[cfg(feature = "image-optimization")]
568fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
569    crate::walk::walk_files(dir, "html")
570}
571
572#[cfg(all(test, feature = "image-optimization"))]
573mod tests {
574    use super::*;
575    use tempfile::tempdir;
576
577    // -------------------------------------------------------------------
578    // Test fixtures
579    // -------------------------------------------------------------------
580
581    /// Writes a tiny programmatically-generated JPEG to the given path.
582    fn write_test_jpeg(path: &Path, w: u32, h: u32) {
583        let buf = image::ImageBuffer::from_fn(w, h, |x, y| {
584            image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
585        });
586        image::DynamicImage::ImageRgb8(buf)
587            .save_with_format(path, image::ImageFormat::Jpeg)
588            .expect("write jpeg");
589    }
590
591    /// Writes a tiny programmatically-generated PNG to the given path.
592    fn write_test_png(path: &Path, w: u32, h: u32) {
593        let buf = image::ImageBuffer::from_fn(w, h, |x, y| {
594            image::Rgba([(x % 256) as u8, (y % 256) as u8, 200, 255])
595        });
596        image::DynamicImage::ImageRgba8(buf)
597            .save_with_format(path, image::ImageFormat::Png)
598            .expect("write png");
599    }
600
601    /// Builds an in-memory `ImageManifest` with the supplied WebP variants.
602    fn manifest_with(
603        original_rel: &str,
604        width: u32,
605        height: u32,
606        variant_widths: &[u32],
607    ) -> HashMap<String, ImageManifest> {
608        let stem = original_rel
609            .rsplit('/')
610            .next()
611            .unwrap_or(original_rel)
612            .rsplit('.')
613            .nth(1)
614            .unwrap_or("img");
615        let webp_variants = variant_widths
616            .iter()
617            .map(|&w| ImageVariant {
618                rel_path: format!("optimized/{stem}-{w}w.webp"),
619                width: w,
620            })
621            .collect();
622        let mut m = HashMap::new();
623        let _ = m.insert(
624            original_rel.to_string(),
625            ImageManifest {
626                original_rel: original_rel.to_string(),
627                original_width: width,
628                original_height: height,
629                webp_variants,
630                avif_variants: Vec::new(),
631            },
632        );
633        m
634    }
635
636    /// Builds a manifest with both WebP and AVIF variants.
637    fn manifest_with_avif(
638        original_rel: &str,
639        width: u32,
640        height: u32,
641        variant_widths: &[u32],
642    ) -> HashMap<String, ImageManifest> {
643        let stem = original_rel
644            .rsplit('/')
645            .next()
646            .unwrap_or(original_rel)
647            .rsplit('.')
648            .nth(1)
649            .unwrap_or("img");
650        let webp_variants = variant_widths
651            .iter()
652            .map(|&w| ImageVariant {
653                rel_path: format!("optimized/{stem}-{w}w.webp"),
654                width: w,
655            })
656            .collect();
657        let avif_variants = variant_widths
658            .iter()
659            .map(|&w| ImageVariant {
660                rel_path: format!("optimized/{stem}-{w}w.avif"),
661                width: w,
662            })
663            .collect();
664        let mut m = HashMap::new();
665        let _ = m.insert(
666            original_rel.to_string(),
667            ImageManifest {
668                original_rel: original_rel.to_string(),
669                original_width: width,
670                original_height: height,
671                webp_variants,
672                avif_variants,
673            },
674        );
675        m
676    }
677
678    // -------------------------------------------------------------------
679    // ImageOptimizationPlugin — struct configuration
680    // -------------------------------------------------------------------
681
682    #[test]
683    fn default_plugin_has_expected_quality_and_breakpoints() {
684        let plugin = ImageOptimizationPlugin::default();
685        assert_eq!(plugin.quality, 80);
686        assert_eq!(plugin.avif_quality, 70);
687        assert!(!plugin.lazy_avif);
688        assert_eq!(plugin.breakpoints, vec![320, 640, 1024, 1440]);
689    }
690
691    #[test]
692    fn plugin_allows_custom_quality_and_breakpoints() {
693        let plugin = ImageOptimizationPlugin {
694            quality: 90,
695            avif_quality: 60,
696            lazy_avif: true,
697            breakpoints: vec![480, 960],
698        };
699        assert_eq!(plugin.quality, 90);
700        assert_eq!(plugin.avif_quality, 60);
701        assert!(plugin.lazy_avif);
702        assert_eq!(plugin.breakpoints, vec![480, 960]);
703    }
704
705    // -------------------------------------------------------------------
706    // encode_avif — direct API contract (issue #521 AC1, AC4)
707    // -------------------------------------------------------------------
708
709    #[test]
710    #[serial_test::parallel(image_encode_avif_failpoint)]
711    fn encode_avif_produces_valid_avif_bytes() {
712        let buf = image::ImageBuffer::from_fn(64, 64, |x, y| {
713            image::Rgb([(x * 4) as u8, (y * 4) as u8, 128])
714        });
715        let img = image::DynamicImage::ImageRgb8(buf);
716        let bytes = encode_avif(&img, 70).expect("encode_avif");
717        assert!(
718            bytes.len() > 12 && &bytes[4..12] == b"ftypavif",
719            "encoded bytes should be a valid ftypavif ISOBMFF file"
720        );
721    }
722
723    #[test]
724    #[serial_test::parallel(image_encode_avif_failpoint)]
725    fn encode_avif_lower_quality_yields_smaller_file() {
726        // Use a non-trivial pattern so quantisation actually has somewhere to
727        // throw bits away (a flat gradient compresses to the same minimum at
728        // both quality levels and breaks the size delta assertion).
729        let buf = image::ImageBuffer::from_fn(96, 96, |x, y| {
730            let r = ((x * y) ^ (x + y)) as u8;
731            let g = (x.wrapping_mul(3).wrapping_add(y)) as u8;
732            let b = ((x ^ y) * 5) as u8;
733            image::Rgb([r, g, b])
734        });
735        let img = image::DynamicImage::ImageRgb8(buf);
736        let high = encode_avif(&img, 80).unwrap();
737        let low = encode_avif(&img, 30).unwrap();
738        // quality=30 should be smaller than quality=80. (Plain assert:
739        // lazily-formatted message args would be uncovered regions.)
740        assert!(low.len() < high.len());
741    }
742
743    #[test]
744    #[serial_test::parallel(image_encode_avif_failpoint)]
745    fn encode_avif_clamps_quality_to_valid_range() {
746        let buf = image::ImageBuffer::from_fn(32, 32, |_, _| {
747            image::Rgb([200_u8, 100, 50])
748        });
749        let img = image::DynamicImage::ImageRgb8(buf);
750        // Quality 0 should be clamped to 1, not blow up.
751        assert!(encode_avif(&img, 0).is_ok());
752        // 101 isn't representable in u8 anyway, but max is fine.
753        assert!(encode_avif(&img, 100).is_ok());
754    }
755
756    #[cfg(feature = "test-fault-injection")]
757    #[test]
758    #[serial_test::serial(image_encode_avif_failpoint)]
759    fn encode_avif_injected_failure_returns_err() {
760        // RAII guard so the global failpoint is always disabled again,
761        // even if the assertion below panics.
762        struct FailGuard;
763        impl Drop for FailGuard {
764            fn drop(&mut self) {
765                let _ = fail::cfg("image::encode-avif", "off");
766            }
767        }
768        let _guard = FailGuard;
769        fail::cfg("image::encode-avif", "return").unwrap();
770
771        let buf =
772            image::ImageBuffer::from_fn(8, 8, |_, _| image::Rgb([1_u8, 2, 3]));
773        let img = image::DynamicImage::ImageRgb8(buf);
774        let err = encode_avif(&img, 70).unwrap_err();
775        assert!(format!("{err}").contains("encode-avif"));
776    }
777
778    #[test]
779    fn name_returns_static_image_optimization_identifier() {
780        assert_eq!(
781            ImageOptimizationPlugin::default().name(),
782            "image-optimization"
783        );
784    }
785
786    // -------------------------------------------------------------------
787    // extract_attr — table-driven over the success / failure paths
788    // -------------------------------------------------------------------
789
790    #[test]
791    fn extract_attr_table_driven_inputs() {
792        let cases: &[(&str, &str, Option<&str>)] = &[
793            (r#"<img src="x.jpg" alt="Photo">"#, "alt", Some("Photo")),
794            (r#"<img src="x.jpg">"#, "alt", None),
795            (r#"<img alt="">"#, "alt", Some("")),
796            (
797                r#"<img alt="multi word value">"#,
798                "alt",
799                Some("multi word value"),
800            ),
801            (r#"<img src="x.jpg" alt="P">"#, "src", Some("x.jpg")),
802            (r"<img>", "src", None),
803            (
804                r#"<img fetchpriority="high" src="x.jpg">"#,
805                "fetchpriority",
806                Some("high"),
807            ),
808        ];
809        for &(tag, attr, expected) in cases {
810            let actual = extract_attr(tag, attr);
811            assert_eq!(
812                actual.as_deref(),
813                expected,
814                "extract_attr({tag:?}, {attr:?}) should be {expected:?}"
815            );
816        }
817    }
818
819    // -------------------------------------------------------------------
820    // rewrite_img_tags — picture element generation
821    // -------------------------------------------------------------------
822
823    #[test]
824    fn rewrite_img_tags_replaces_img_with_picture_element() {
825        let manifest =
826            manifest_with("images/photo.jpg", 2000, 1500, &[640, 1024]);
827        let html = r#"<img src="images/photo.jpg" alt="A photo">"#;
828
829        let result = rewrite_img_tags(html, &manifest);
830
831        assert!(result.contains("<picture>"));
832        assert!(result.contains("</picture>"));
833        assert!(result.contains(r#"type="image/webp""#));
834        assert!(result.contains("srcset="));
835        assert!(result.contains("640w"));
836        assert!(result.contains("1024w"));
837        assert!(result.contains(r#"loading="lazy""#));
838        assert!(result.contains(r#"decoding="async""#));
839        assert!(result.contains(r#"width="2000""#));
840        assert!(result.contains(r#"height="1500""#));
841        assert!(result.contains(r#"alt="A photo""#));
842    }
843
844    #[test]
845    fn rewrite_img_tags_preserves_alt_text() {
846        let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
847        let html = r#"<img src="a.jpg" alt="Important context">"#;
848        let result = rewrite_img_tags(html, &manifest);
849        assert!(result.contains(r#"alt="Important context""#));
850    }
851
852    #[test]
853    fn rewrite_img_tags_handles_missing_alt_with_empty_string() {
854        let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
855        let html = r#"<img src="a.jpg">"#;
856        let result = rewrite_img_tags(html, &manifest);
857        assert!(result.contains(r#"alt="""#));
858    }
859
860    #[test]
861    fn rewrite_img_tags_handles_absolute_src_path() {
862        let manifest = manifest_with("images/a.jpg", 2000, 1000, &[640]);
863        let html = r#"<img src="/images/a.jpg" alt="">"#;
864        let result = rewrite_img_tags(html, &manifest);
865        assert!(result.contains("<picture>"));
866    }
867
868    #[test]
869    fn rewrite_img_tags_no_match_returns_unchanged() {
870        let manifest = manifest_with("ghost.jpg", 100, 100, &[640]);
871        let html = r"<p>no images here</p>";
872        let result = rewrite_img_tags(html, &manifest);
873        assert_eq!(result, html);
874    }
875
876    #[test]
877    fn rewrite_img_tags_skips_entries_with_no_variants() {
878        let manifest = manifest_with("a.jpg", 2000, 1000, &[]);
879        let html = r#"<img src="a.jpg" alt="x">"#;
880        let result = rewrite_img_tags(html, &manifest);
881        assert_eq!(result, html, "no variants → no rewrite");
882    }
883
884    // -------------------------------------------------------------------
885    // rewrite_img_tags — srcset format
886    // -------------------------------------------------------------------
887
888    #[test]
889    fn rewrite_img_tags_builds_srcset_with_width_descriptors() {
890        let manifest =
891            manifest_with("a.jpg", 4000, 3000, &[320, 640, 1024, 1440]);
892        let html = r#"<img src="a.jpg" alt="">"#;
893        let result = rewrite_img_tags(html, &manifest);
894        for w in [320, 640, 1024, 1440] {
895            assert!(
896                result.contains(&format!("{w}w")),
897                "srcset should contain {w}w:\n{result}"
898            );
899        }
900        assert!(result.matches(", ").count() >= 3);
901    }
902
903    #[test]
904    fn rewrite_img_tags_srcset_paths_are_absolute() {
905        let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
906        let html = r#"<img src="a.jpg" alt="">"#;
907        let result = rewrite_img_tags(html, &manifest);
908        assert!(
909            result.contains("/optimized/a-640w.webp 640w"),
910            "srcset paths should be absolute: {result}"
911        );
912    }
913
914    // -------------------------------------------------------------------
915    // rewrite_img_tags — lazy loading defaults
916    // -------------------------------------------------------------------
917
918    #[test]
919    fn rewrite_img_tags_default_loading_is_lazy() {
920        let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
921        let html = r#"<img src="a.jpg" alt="">"#;
922        let result = rewrite_img_tags(html, &manifest);
923        assert!(result.contains(r#"loading="lazy""#));
924        assert!(result.contains(r#"decoding="async""#));
925    }
926
927    #[test]
928    fn rewrite_img_tags_fetchpriority_high_gets_eager_loading() {
929        let manifest = manifest_with("hero.jpg", 2000, 1000, &[640]);
930        let html = r#"<img src="hero.jpg" alt="Hero" fetchpriority="high">"#;
931        let result = rewrite_img_tags(html, &manifest);
932        assert!(
933            result.contains(r#"loading="eager""#),
934            "fetchpriority=high should produce loading=eager: {result}"
935        );
936        assert!(
937            result.contains(r#"fetchpriority="high""#),
938            "fetchpriority attribute should be preserved: {result}"
939        );
940    }
941
942    #[test]
943    fn rewrite_img_tags_fetchpriority_low_still_lazy() {
944        let manifest = manifest_with("bg.jpg", 2000, 1000, &[640]);
945        let html = r#"<img src="bg.jpg" alt="" fetchpriority="low">"#;
946        let result = rewrite_img_tags(html, &manifest);
947        assert!(result.contains(r#"loading="lazy""#));
948    }
949
950    // -------------------------------------------------------------------
951    // rewrite_img_tags — AVIF + WebP picture element
952    // -------------------------------------------------------------------
953
954    #[test]
955    fn rewrite_img_tags_includes_avif_source_when_present() {
956        let manifest =
957            manifest_with_avif("photo.jpg", 2000, 1500, &[640, 1024]);
958        let html = r#"<img src="photo.jpg" alt="">"#;
959        let result = rewrite_img_tags(html, &manifest);
960
961        assert!(
962            result.contains(r#"type="image/avif""#),
963            "should have AVIF source: {result}"
964        );
965        assert!(
966            result.contains(r#"type="image/webp""#),
967            "should have WebP source: {result}"
968        );
969
970        // AVIF should come before WebP (browser picks first match)
971        let avif_pos = result.find("image/avif").expect("avif present");
972        let webp_pos = result.find("image/webp").expect("webp present");
973        assert!(
974            avif_pos < webp_pos,
975            "AVIF source should precede WebP source"
976        );
977    }
978
979    #[test]
980    fn rewrite_img_tags_avif_srcset_has_correct_format() {
981        let manifest = manifest_with_avif("photo.jpg", 2000, 1500, &[320, 640]);
982        let html = r#"<img src="photo.jpg" alt="">"#;
983        let result = rewrite_img_tags(html, &manifest);
984
985        assert!(
986            result.contains("/optimized/photo-320w.avif 320w"),
987            "AVIF srcset should have width descriptors: {result}"
988        );
989        assert!(
990            result.contains("/optimized/photo-640w.avif 640w"),
991            "AVIF srcset should have width descriptors: {result}"
992        );
993    }
994
995    // -------------------------------------------------------------------
996    // rewrite_img_tags — width/height preservation
997    // -------------------------------------------------------------------
998
999    #[test]
1000    fn rewrite_img_tags_injects_dimensions_from_manifest() {
1001        let manifest = manifest_with("a.jpg", 1920, 1080, &[640]);
1002        let html = r#"<img src="a.jpg" alt="">"#;
1003        let result = rewrite_img_tags(html, &manifest);
1004        assert!(result.contains(r#"width="1920""#));
1005        assert!(result.contains(r#"height="1080""#));
1006    }
1007
1008    #[test]
1009    fn rewrite_img_tags_preserves_explicit_width_height() {
1010        let manifest = manifest_with("a.jpg", 1920, 1080, &[640]);
1011        let html = r#"<img src="a.jpg" alt="" width="800" height="450">"#;
1012        let result = rewrite_img_tags(html, &manifest);
1013        assert!(
1014            result.contains(r#"width="800""#),
1015            "explicit width should be preserved: {result}"
1016        );
1017        assert!(
1018            result.contains(r#"height="450""#),
1019            "explicit height should be preserved: {result}"
1020        );
1021    }
1022
1023    #[test]
1024    fn rewrite_img_tags_falls_back_to_manifest_dimensions_on_unparseable_attrs()
1025    {
1026        // Non-numeric width/height attributes fail `.parse::<u32>()`,
1027        // exercising the `.ok()` → `None` → `unwrap_or(entry.original_*)`
1028        // fallback for both dimensions.
1029        let manifest = manifest_with("a.jpg", 1920, 1080, &[640]);
1030        let html = r#"<img src="a.jpg" alt="" width="abc" height="xyz">"#;
1031        let result = rewrite_img_tags(html, &manifest);
1032        assert!(
1033            result.contains(r#"width="1920""#),
1034            "unparseable width should fall back to manifest width: {result}"
1035        );
1036        assert!(
1037            result.contains(r#"height="1080""#),
1038            "unparseable height should fall back to manifest height: {result}"
1039        );
1040    }
1041
1042    #[test]
1043    fn rewrite_img_tags_only_replaces_first_occurrence_per_image() {
1044        let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
1045        let html = r#"<img src="a.jpg"><img src="a.jpg">"#;
1046        let result = rewrite_img_tags(html, &manifest);
1047        assert_eq!(result.matches("<picture>").count(), 1);
1048    }
1049
1050    // -------------------------------------------------------------------
1051    // collect_images — extension filter + optimized-dir skip
1052    // -------------------------------------------------------------------
1053
1054    #[test]
1055    fn collect_images_skips_optimized_subdirectory() {
1056        let dir = tempdir().expect("tempdir");
1057        let site = dir.path().join("site");
1058        let opt = site.join("optimized");
1059        fs::create_dir_all(&opt).unwrap();
1060
1061        fs::write(site.join("photo.jpg"), [0xFF, 0xD8]).unwrap();
1062        fs::write(opt.join("photo-640w.webp"), [0]).unwrap();
1063
1064        let images = collect_images(&site).unwrap();
1065        assert_eq!(images.len(), 1);
1066        assert!(images[0].ends_with("photo.jpg"));
1067    }
1068
1069    #[test]
1070    fn collect_images_filters_to_jpg_jpeg_png_only() {
1071        let dir = tempdir().expect("tempdir");
1072        for name in ["a.jpg", "b.jpeg", "c.png", "d.gif", "e.webp", "f.txt"] {
1073            fs::write(dir.path().join(name), [0]).unwrap();
1074        }
1075        let images = collect_images(dir.path()).unwrap();
1076        assert_eq!(images.len(), 3, "only jpg/jpeg/png should be collected");
1077    }
1078
1079    #[test]
1080    fn collect_images_extension_match_is_case_insensitive() {
1081        let dir = tempdir().expect("tempdir");
1082        for name in ["A.JPG", "B.PNG", "C.JPEG"] {
1083            fs::write(dir.path().join(name), [0]).unwrap();
1084        }
1085        let images = collect_images(dir.path()).unwrap();
1086        assert_eq!(images.len(), 3);
1087    }
1088
1089    #[test]
1090    fn collect_images_recurses_into_nested_subdirectories() {
1091        let dir = tempdir().expect("tempdir");
1092        let nested = dir.path().join("a").join("b");
1093        fs::create_dir_all(&nested).unwrap();
1094        fs::write(dir.path().join("top.jpg"), [0]).unwrap();
1095        fs::write(nested.join("deep.png"), [0]).unwrap();
1096
1097        let images = collect_images(dir.path()).unwrap();
1098        assert_eq!(images.len(), 2);
1099    }
1100
1101    #[test]
1102    fn collect_images_returns_empty_for_missing_directory() {
1103        let dir = tempdir().expect("tempdir");
1104        let result = collect_images(&dir.path().join("missing")).unwrap();
1105        assert!(result.is_empty());
1106    }
1107
1108    #[test]
1109    fn collect_images_returns_results_sorted() {
1110        let dir = tempdir().expect("tempdir");
1111        for name in ["zebra.jpg", "apple.jpg", "mango.jpg"] {
1112            fs::write(dir.path().join(name), [0]).unwrap();
1113        }
1114        let images = collect_images(dir.path()).unwrap();
1115        let names: Vec<_> = images
1116            .iter()
1117            .map(|p| p.file_name().unwrap().to_str().unwrap())
1118            .collect();
1119        assert_eq!(names, vec!["apple.jpg", "mango.jpg", "zebra.jpg"]);
1120    }
1121
1122    // -------------------------------------------------------------------
1123    // collect_html_files — recursion + filtering
1124    // -------------------------------------------------------------------
1125
1126    #[test]
1127    fn collect_html_files_filters_non_html_extensions() {
1128        let dir = tempdir().expect("tempdir");
1129        fs::write(dir.path().join("a.html"), "").unwrap();
1130        fs::write(dir.path().join("b.css"), "").unwrap();
1131
1132        let result = collect_html_files(dir.path()).unwrap();
1133        assert_eq!(result.len(), 1);
1134    }
1135
1136    #[test]
1137    fn collect_html_files_recurses_and_sorts() {
1138        let dir = tempdir().expect("tempdir");
1139        let nested = dir.path().join("blog");
1140        fs::create_dir_all(&nested).unwrap();
1141        fs::write(dir.path().join("index.html"), "").unwrap();
1142        fs::write(nested.join("post.html"), "").unwrap();
1143
1144        let result = collect_html_files(dir.path()).unwrap();
1145        assert_eq!(result.len(), 2);
1146    }
1147
1148    // -------------------------------------------------------------------
1149    // after_compile — short-circuit paths (no real images)
1150    // -------------------------------------------------------------------
1151
1152    #[test]
1153    fn after_compile_missing_site_dir_returns_ok() {
1154        let dir = tempdir().expect("tempdir");
1155        let missing = dir.path().join("missing");
1156        let ctx =
1157            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
1158        ImageOptimizationPlugin::default()
1159            .after_compile(&ctx)
1160            .expect("missing site is not an error");
1161        assert!(!missing.exists());
1162    }
1163
1164    // -------------------------------------------------------------------
1165    // process_image — real JPEG/PNG round-trip
1166    // -------------------------------------------------------------------
1167
1168    #[test]
1169    #[serial_test::parallel(image_encode_avif_failpoint)]
1170    fn process_image_generates_webp_variants_below_original_width() {
1171        let dir = tempdir().expect("tempdir");
1172        let site = dir.path().join("site");
1173        let opt = site.join("optimized");
1174        fs::create_dir_all(&opt).unwrap();
1175
1176        let src = site.join("hero.jpg");
1177        write_test_jpeg(&src, 2000, 1000);
1178
1179        let manifest = process_image(
1180            &src,
1181            &site,
1182            &opt,
1183            &[320, 640, 1024, 1440],
1184            DEFAULT_QUALITY,
1185            DEFAULT_AVIF_QUALITY,
1186        )
1187        .unwrap();
1188        assert_eq!(manifest.original_width, 2000);
1189        assert_eq!(manifest.original_height, 1000);
1190        assert_eq!(manifest.original_rel, "hero.jpg");
1191
1192        // Every breakpoint strictly less than 2000 must produce a variant.
1193        assert_eq!(manifest.webp_variants.len(), 4);
1194        for v in &manifest.webp_variants {
1195            assert!(opt
1196                .join(v.rel_path.trim_start_matches("optimized/"))
1197                .exists());
1198            assert!(v.width < 2000);
1199        }
1200
1201        // AVIF variants generated in parallel with WebP at each breakpoint.
1202        assert_eq!(manifest.avif_variants.len(), 4);
1203        for v in &manifest.avif_variants {
1204            let path = opt.join(v.rel_path.trim_start_matches("optimized/"));
1205            assert!(path.exists(), "AVIF variant must exist on disk: {path:?}");
1206            let bytes = fs::read(&path).unwrap();
1207            // ISO BMFF AVIF magic: bytes 4..12 == "ftypavif" (or "ftypavis"
1208            // for sequences). We only emit still images so it's always "avif".
1209            assert!(
1210                bytes.len() > 12 && &bytes[4..12] == b"ftypavif",
1211                "AVIF file should start with ftypavif box: {path:?}"
1212            );
1213        }
1214    }
1215
1216    #[test]
1217    fn process_image_skips_widths_larger_than_original() {
1218        let dir = tempdir().expect("tempdir");
1219        let site = dir.path().join("site");
1220        let opt = site.join("optimized");
1221        fs::create_dir_all(&opt).unwrap();
1222
1223        let src = site.join("small.png");
1224        write_test_png(&src, 500, 500);
1225
1226        let manifest = process_image(
1227            &src,
1228            &site,
1229            &opt,
1230            &[320, 640, 1024, 1440],
1231            DEFAULT_QUALITY,
1232            DEFAULT_AVIF_QUALITY,
1233        )
1234        .unwrap();
1235        // Only 320 should survive (320 < 500).
1236        assert_eq!(manifest.webp_variants.len(), 1);
1237        assert_eq!(manifest.webp_variants[0].width, 320);
1238        assert_eq!(manifest.avif_variants.len(), 1);
1239        assert_eq!(manifest.avif_variants[0].width, 320);
1240    }
1241
1242    #[test]
1243    #[serial_test::parallel(image_encode_avif_failpoint)]
1244    fn process_image_uses_custom_breakpoints() {
1245        let dir = tempdir().expect("tempdir");
1246        let site = dir.path().join("site");
1247        let opt = site.join("optimized");
1248        fs::create_dir_all(&opt).unwrap();
1249
1250        let src = site.join("photo.jpg");
1251        write_test_jpeg(&src, 2000, 1000);
1252
1253        let manifest = process_image(
1254            &src,
1255            &site,
1256            &opt,
1257            &[480, 960],
1258            DEFAULT_QUALITY,
1259            DEFAULT_AVIF_QUALITY,
1260        )
1261        .unwrap();
1262        assert_eq!(manifest.webp_variants.len(), 2);
1263        assert_eq!(manifest.webp_variants[0].width, 480);
1264        assert_eq!(manifest.webp_variants[1].width, 960);
1265    }
1266
1267    #[test]
1268    fn process_image_rejects_unreadable_source_path() {
1269        let dir = tempdir().expect("tempdir");
1270        let opt = dir.path().join("opt");
1271        fs::create_dir_all(&opt).unwrap();
1272        let missing = dir.path().join("does-not-exist.jpg");
1273        assert!(process_image(
1274            &missing,
1275            dir.path(),
1276            &opt,
1277            DEFAULT_BREAKPOINTS,
1278            DEFAULT_QUALITY,
1279            DEFAULT_AVIF_QUALITY
1280        )
1281        .is_err());
1282    }
1283
1284    // -------------------------------------------------------------------
1285    // after_compile — end-to-end on real images
1286    // -------------------------------------------------------------------
1287
1288    #[test]
1289    #[serial_test::parallel(image_encode_avif_failpoint)]
1290    fn after_compile_processes_real_images_and_rewrites_html() {
1291        let dir = tempdir().expect("tempdir");
1292        let site = dir.path().join("site");
1293        let images = site.join("images");
1294        fs::create_dir_all(&images).unwrap();
1295
1296        write_test_jpeg(&images.join("photo.jpg"), 2000, 1500);
1297        fs::write(
1298            site.join("index.html"),
1299            r#"<html><head></head><body><img src="/images/photo.jpg" alt="Test"></body></html>"#,
1300        )
1301        .unwrap();
1302
1303        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1304        ImageOptimizationPlugin::default()
1305            .after_compile(&ctx)
1306            .unwrap();
1307
1308        // Original file preserved.
1309        assert!(images.join("photo.jpg").exists());
1310        // Optimized directory populated.
1311        assert!(site.join("optimized").exists());
1312        // HTML rewritten to <picture>.
1313        let html = fs::read_to_string(site.join("index.html")).unwrap();
1314        assert!(html.contains("<picture>"));
1315        assert!(html.contains("image/webp"));
1316        assert!(html.contains(r#"alt="Test""#));
1317        assert!(html.contains(r#"loading="lazy""#));
1318        assert!(html.contains(r#"decoding="async""#));
1319    }
1320
1321    #[test]
1322    fn after_compile_failed_image_processing_logs_warn_and_continues() {
1323        let dir = tempdir().expect("tempdir");
1324        let site = dir.path().join("site");
1325        fs::create_dir_all(&site).unwrap();
1326
1327        fs::write(site.join("broken.jpg"), b"this is not really a jpeg")
1328            .unwrap();
1329
1330        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1331        ImageOptimizationPlugin::default()
1332            .after_compile(&ctx)
1333            .expect("broken image must not propagate");
1334    }
1335
1336    #[test]
1337    fn after_compile_html_without_image_refs_skips_rewrite() {
1338        let dir = tempdir().expect("tempdir");
1339        let site = dir.path().join("site");
1340        let images = site.join("images");
1341        fs::create_dir_all(&images).unwrap();
1342
1343        write_test_jpeg(&images.join("orphan.jpg"), 1000, 1000);
1344        let original_html =
1345            "<html><head></head><body><p>no images here</p></body></html>";
1346        fs::write(site.join("index.html"), original_html).unwrap();
1347
1348        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1349        ImageOptimizationPlugin::default()
1350            .after_compile(&ctx)
1351            .unwrap();
1352
1353        let after = fs::read_to_string(site.join("index.html")).unwrap();
1354        assert_eq!(
1355            after, original_html,
1356            "html with no image refs should not be rewritten"
1357        );
1358    }
1359
1360    #[test]
1361    fn after_compile_no_images_short_circuits_without_creating_optimized_dir() {
1362        let dir = tempdir().expect("tempdir");
1363        let site = dir.path().join("site");
1364        fs::create_dir_all(&site).unwrap();
1365        fs::write(site.join("index.html"), "<p></p>").unwrap();
1366
1367        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1368        ImageOptimizationPlugin::default()
1369            .after_compile(&ctx)
1370            .unwrap();
1371        assert!(!site.join("optimized").exists());
1372    }
1373
1374    // -------------------------------------------------------------------
1375    // rewrite_img_tags — skip branches
1376    // -------------------------------------------------------------------
1377
1378    #[test]
1379    fn rewrite_img_tags_skips_img_without_src() {
1380        let manifest = manifest_with("photo.jpg", 100, 80, &[320]);
1381        let html = "<img alt=\"no source\">";
1382        assert_eq!(rewrite_img_tags(html, &manifest), html);
1383    }
1384
1385    #[test]
1386    fn rewrite_img_tags_skips_src_not_in_manifest() {
1387        let manifest = manifest_with("photo.jpg", 100, 80, &[320]);
1388        let html = "<img src=\"/unknown.jpg\">";
1389        assert_eq!(rewrite_img_tags(html, &manifest), html);
1390    }
1391
1392    #[test]
1393    fn rewrite_img_tags_logs_when_replacing_author_srcset() {
1394        crate::test_support::init_logger();
1395        let manifest = manifest_with("photo.jpg", 100, 80, &[320]);
1396        let html = "<img src=\"/photo.jpg\" srcset=\"old.jpg 1x\">";
1397        let out = rewrite_img_tags(html, &manifest);
1398        assert!(out.contains("<picture>"));
1399        assert!(!out.contains("old.jpg"));
1400    }
1401
1402    #[test]
1403    fn rewrite_img_tags_avif_only_entry_omits_webp_source() {
1404        let mut manifest = HashMap::new();
1405        let _ = manifest.insert(
1406            "photo.jpg".to_string(),
1407            ImageManifest {
1408                original_rel: "photo.jpg".to_string(),
1409                original_width: 100,
1410                original_height: 80,
1411                webp_variants: Vec::new(),
1412                avif_variants: vec![ImageVariant {
1413                    rel_path: "optimized/photo-320w.avif".to_string(),
1414                    width: 320,
1415                }],
1416            },
1417        );
1418        let out = rewrite_img_tags("<img src=\"/photo.jpg\">", &manifest);
1419        assert!(out.contains("image/avif"));
1420        assert!(!out.contains("image/webp"));
1421    }
1422
1423    #[test]
1424    fn unwrap_rewrite_falls_back_to_original_on_error() {
1425        crate::test_support::init_logger();
1426        let err = SsgError::io(
1427            std::io::Error::other("synthetic lol_html failure"),
1428            "<lol_html>",
1429        );
1430        let out = unwrap_rewrite("<p>original</p>", Err(err));
1431        assert_eq!(out, "<p>original</p>");
1432    }
1433
1434    #[test]
1435    fn extract_attr_returns_none_for_unterminated_value() {
1436        assert!(extract_attr("<img alt=\"unterminated", "alt").is_none());
1437    }
1438
1439    // -------------------------------------------------------------------
1440    // encode/save error branches
1441    // -------------------------------------------------------------------
1442
1443    #[test]
1444    fn encode_avif_rejects_empty_image() {
1445        let img = image::DynamicImage::new_rgb8(0, 0);
1446        assert!(encode_avif(&img, 70).is_err());
1447    }
1448
1449    #[test]
1450    fn process_image_webp_save_fails_when_variant_squatted_by_dir() {
1451        let dir = tempdir().unwrap();
1452        let site = dir.path().join("site");
1453        let optimized = site.join("optimized");
1454        fs::create_dir_all(&optimized).unwrap();
1455        write_test_jpeg(&site.join("photo.jpg"), 400, 20);
1456        // A directory squats the WebP variant path, so `resized.save`
1457        // fails and the Io closure fires.
1458        fs::create_dir_all(optimized.join("photo-320w.webp")).unwrap();
1459
1460        let res = process_image(
1461            &site.join("photo.jpg"),
1462            &site,
1463            &optimized,
1464            &[320],
1465            80,
1466            70,
1467        );
1468        assert!(res.is_err());
1469    }
1470
1471    #[test]
1472    #[serial_test::parallel(image_encode_avif_failpoint)]
1473    fn process_image_avif_write_failure_logs_and_skips_variant() {
1474        crate::test_support::init_logger();
1475        let dir = tempdir().unwrap();
1476        let site = dir.path().join("site");
1477        let optimized = site.join("optimized");
1478        fs::create_dir_all(&optimized).unwrap();
1479        write_test_jpeg(&site.join("photo.jpg"), 400, 20);
1480        // A directory squats the AVIF variant path: encoding succeeds
1481        // but fs::write fails, which is logged and skipped.
1482        fs::create_dir_all(optimized.join("photo-320w.avif")).unwrap();
1483
1484        let entry = process_image(
1485            &site.join("photo.jpg"),
1486            &site,
1487            &optimized,
1488            &[320],
1489            80,
1490            70,
1491        )
1492        .unwrap();
1493        assert_eq!(entry.webp_variants.len(), 1);
1494        assert!(entry.avif_variants.is_empty());
1495    }
1496
1497    #[cfg(feature = "test-fault-injection")]
1498    #[test]
1499    #[serial_test::serial(image_encode_avif_failpoint)]
1500    fn process_image_avif_encode_failure_logs_and_skips_variant() {
1501        // Distinct from the write-failure case above: here `encode_avif`
1502        // itself fails (via the injected failpoint), exercising the
1503        // outer `Err(e) => { log::warn!(...); None }` arm in
1504        // `process_image`'s AVIF results loop rather than the inner
1505        // fs::write failure arm.
1506        crate::test_support::init_logger();
1507        struct FailGuard;
1508        impl Drop for FailGuard {
1509            fn drop(&mut self) {
1510                let _ = fail::cfg("image::encode-avif", "off");
1511            }
1512        }
1513        let _guard = FailGuard;
1514        fail::cfg("image::encode-avif", "return").unwrap();
1515
1516        let dir = tempdir().unwrap();
1517        let site = dir.path().join("site");
1518        let optimized = site.join("optimized");
1519        fs::create_dir_all(&optimized).unwrap();
1520        write_test_jpeg(&site.join("photo.jpg"), 400, 20);
1521
1522        let entry = process_image(
1523            &site.join("photo.jpg"),
1524            &site,
1525            &optimized,
1526            &[320],
1527            80,
1528            70,
1529        )
1530        .unwrap();
1531        assert_eq!(
1532            entry.webp_variants.len(),
1533            1,
1534            "WebP path is unaffected by the AVIF failpoint"
1535        );
1536        assert!(
1537            entry.avif_variants.is_empty(),
1538            "AVIF variant must be skipped when encoding fails"
1539        );
1540    }
1541
1542    // -------------------------------------------------------------------
1543    // after_compile / rewrite_html_img_tags — IO error branches
1544    // -------------------------------------------------------------------
1545
1546    #[test]
1547    #[cfg(unix)]
1548    fn after_compile_fails_when_site_has_unreadable_subdir() {
1549        use std::os::unix::fs::PermissionsExt;
1550        let dir = tempdir().unwrap();
1551        let site = dir.path().join("site");
1552        let locked = site.join("locked");
1553        fs::create_dir_all(&locked).unwrap();
1554        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1555            .unwrap();
1556
1557        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1558        let res = ImageOptimizationPlugin::default().after_compile(&ctx);
1559
1560        let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1561        // Root CI runners bypass perms; only assert when it errored.
1562        if let Err(e) = res {
1563            assert!(!format!("{e}").is_empty());
1564        }
1565    }
1566
1567    #[test]
1568    fn after_compile_fails_when_optimized_dir_squatted_by_file() {
1569        let dir = tempdir().unwrap();
1570        let site = dir.path().join("site");
1571        fs::create_dir_all(&site).unwrap();
1572        // Tiny image: below every default breakpoint, so no encoding
1573        // happens — but images is non-empty so create_dir_all runs.
1574        write_test_jpeg(&site.join("tiny.jpg"), 16, 16);
1575        fs::write(site.join("optimized"), "not a dir").unwrap();
1576
1577        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1578        let err = ImageOptimizationPlugin::default()
1579            .after_compile(&ctx)
1580            .unwrap_err();
1581        assert!(!format!("{err}").is_empty());
1582    }
1583
1584    #[test]
1585    #[cfg(unix)]
1586    fn after_compile_fails_when_html_is_unreadable() {
1587        use std::os::unix::fs::PermissionsExt;
1588        let dir = tempdir().unwrap();
1589        let site = dir.path().join("site");
1590        fs::create_dir_all(&site).unwrap();
1591        write_test_jpeg(&site.join("tiny.jpg"), 16, 16);
1592        let html = site.join("index.html");
1593        fs::write(&html, "<p>x</p>").unwrap();
1594        fs::set_permissions(&html, fs::Permissions::from_mode(0o000)).unwrap();
1595
1596        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1597        let res = ImageOptimizationPlugin::default().after_compile(&ctx);
1598
1599        let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
1600        if let Err(e) = res {
1601            assert!(!format!("{e}").is_empty());
1602        }
1603    }
1604
1605    #[test]
1606    #[cfg(unix)]
1607    #[serial_test::parallel(image_encode_avif_failpoint)]
1608    fn after_compile_fails_when_html_is_readonly() {
1609        use std::os::unix::fs::PermissionsExt;
1610        let dir = tempdir().unwrap();
1611        let site = dir.path().join("site");
1612        fs::create_dir_all(&site).unwrap();
1613        // Wide enough for one 320w variant, so the HTML actually
1614        // changes and the write is attempted.
1615        write_test_jpeg(&site.join("photo.jpg"), 400, 20);
1616        let html = site.join("index.html");
1617        fs::write(&html, "<img src=\"/photo.jpg\">").unwrap();
1618        fs::set_permissions(&html, fs::Permissions::from_mode(0o444)).unwrap();
1619
1620        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1621        let plugin = ImageOptimizationPlugin {
1622            breakpoints: vec![320],
1623            ..Default::default()
1624        };
1625        let res = plugin.after_compile(&ctx);
1626
1627        let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
1628        if let Err(e) = res {
1629            assert!(!format!("{e}").is_empty());
1630        }
1631    }
1632
1633    #[test]
1634    #[cfg(unix)]
1635    fn rewrite_html_img_tags_fails_on_unreadable_subdir() {
1636        use std::os::unix::fs::PermissionsExt;
1637        let dir = tempdir().unwrap();
1638        let site = dir.path().join("site");
1639        let locked = site.join("locked");
1640        fs::create_dir_all(&locked).unwrap();
1641        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1642            .unwrap();
1643
1644        let res = rewrite_html_img_tags(&site, &HashMap::new());
1645
1646        let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1647        if let Err(e) = res {
1648            assert!(!format!("{e}").is_empty());
1649        }
1650    }
1651}