1#[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#[cfg(feature = "image-optimization")]
39const DEFAULT_BREAKPOINTS: &[u32] = &[320, 640, 1024, 1440];
40
41#[cfg(feature = "image-optimization")]
43const DEFAULT_QUALITY: u8 = 80;
44
45#[cfg(feature = "image-optimization")]
51const DEFAULT_AVIF_QUALITY: u8 = 70;
52
53#[cfg(feature = "image-optimization")]
57const AVIF_SPEED: u8 = 4;
58
59#[cfg(feature = "image-optimization")]
69#[derive(Debug, Clone)]
70pub struct ImageOptimizationPlugin {
71 pub quality: u8,
73 pub avif_quality: u8,
76 pub lazy_avif: bool,
89 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#[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#[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#[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 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; }
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 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 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#[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 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#[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 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 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 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#[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 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#[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 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 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 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 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 #[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 #[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 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 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 assert!(encode_avif(&img, 0).is_ok());
752 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 assert!(images.join("photo.jpg").exists());
1310 assert!(site.join("optimized").exists());
1312 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 #[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 #[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 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 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 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 #[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 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 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 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}