1use crate::error::{PathErrorExt, SsgError};
14use crate::plugin::{Plugin, PluginContext};
15use rayon::prelude::*;
16use std::fs;
17use std::path::PathBuf;
18use std::sync::atomic::{AtomicUsize, Ordering};
19
20#[derive(Debug, Copy, Clone)]
38pub struct MinifyPlugin;
39
40impl Plugin for MinifyPlugin {
41 fn name(&self) -> &'static str {
42 "minify"
43 }
44
45 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
46 if !ctx.site_dir.exists() {
47 return Ok(());
48 }
49
50 let cache = ctx.cache.as_ref();
51 let (html_files, css_files, js_files) =
52 collect_minifiable_files(&ctx.site_dir, cache)?;
53
54 let count = AtomicUsize::new(0);
55
56 html_files
57 .par_iter()
58 .try_for_each(|path| -> Result<(), SsgError> {
59 fail_point!("plugins::minify-read", |_| {
60 Err(SsgError::Io {
61 path: path.clone(),
62 source: std::io::Error::other(
63 "injected: plugins::minify-read",
64 ),
65 })
66 });
67 let content = fs::read_to_string(path).with_path(path)?;
68 let minified = minify_html(&content);
69 fail_point!("plugins::minify-write", |_| {
70 Err(SsgError::Io {
71 path: path.clone(),
72 source: std::io::Error::other(
73 "injected: plugins::minify-write",
74 ),
75 })
76 });
77 fs::write(path, &minified).with_path(path)?;
78 let _ = count.fetch_add(1, Ordering::Relaxed);
79 Ok(())
80 })?;
81
82 css_files
88 .par_iter()
89 .try_for_each(|path| -> Result<(), SsgError> {
90 let content = fs::read_to_string(path).with_path(path)?;
91 let minified = minify_css(&content);
92 fs::write(path, &minified).with_path(path)?;
93 let _ = count.fetch_add(1, Ordering::Relaxed);
94 Ok(())
95 })?;
96
97 js_files
98 .par_iter()
99 .try_for_each(|path| -> Result<(), SsgError> {
100 let content = fs::read_to_string(path).with_path(path)?;
101 let minified = minify_js(&content);
102 fs::write(path, &minified).with_path(path)?;
103 let _ = count.fetch_add(1, Ordering::Relaxed);
104 Ok(())
105 })?;
106
107 let total = count.load(Ordering::Relaxed);
108 if total > 0 {
109 println!("[minify] Processed {total} file(s)");
110 }
111 Ok(())
112 }
113}
114
115type MinifiableFiles = (Vec<PathBuf>, Vec<PathBuf>, Vec<PathBuf>);
117
118fn collect_minifiable_files(
127 site_dir: &std::path::Path,
128 cache: Option<&crate::plugin::PluginCache>,
129) -> Result<MinifiableFiles, SsgError> {
130 let mut html = Vec::new();
131 let mut css = Vec::new();
132 let mut js = Vec::new();
133
134 drop(fs::read_dir(site_dir).with_path(site_dir)?);
138
139 let mut stack = vec![site_dir.to_path_buf()];
140 while let Some(dir) = stack.pop() {
141 let Ok(entries) = fs::read_dir(&dir) else {
142 continue;
143 };
144 for entry in entries.filter_map(Result::ok) {
145 let Ok(file_type) = entry.file_type() else {
146 continue;
147 };
148 if file_type.is_symlink() {
149 continue;
150 }
151 let path = entry.path();
152 if file_type.is_dir() {
153 stack.push(path);
154 continue;
155 }
156 if !file_type.is_file() {
157 continue;
158 }
159 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
160 continue;
161 };
162 if cache.is_some_and(|c| !c.has_changed(&path)) {
164 continue;
165 }
166 match ext {
167 "html" => html.push(path),
168 "css" => css.push(path),
169 "js" => js.push(path),
170 _ => {}
171 }
172 }
173 }
174 Ok((html, css, js))
175}
176
177const RAW_TEXT_ELEMENTS: [&str; 4] = ["pre", "textarea", "script", "style"];
183
184fn scan_tag(html: &str, start: usize) -> (usize, Option<String>) {
190 let bytes = html.as_bytes();
191 let mut i = start + 1;
192 let closing = bytes.get(i) == Some(&b'/');
193 if closing {
194 i += 1;
195 }
196 let name_start = i;
197 while i < bytes.len()
198 && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-')
199 {
200 i += 1;
201 }
202 let name = if i > name_start && !closing {
203 Some(html[name_start..i].to_ascii_lowercase())
204 } else {
205 None
206 };
207 let mut quote: Option<u8> = None;
208 while i < bytes.len() {
209 let c = bytes[i];
210 match quote {
211 Some(q) => {
212 if c == q {
213 quote = None;
214 }
215 }
216 None => {
217 if c == b'"' || c == b'\'' {
218 quote = Some(c);
219 } else if c == b'>' {
220 return (i + 1, name);
221 }
222 }
223 }
224 i += 1;
225 }
226 (bytes.len(), name)
227}
228
229fn find_closing_tag(hay: &[u8], name: &str) -> Option<usize> {
231 let n = name.as_bytes();
232 let mut i = 0;
233 while i + 2 + n.len() <= hay.len() {
234 if hay[i] == b'<'
235 && hay[i + 1] == b'/'
236 && hay[i + 2..i + 2 + n.len()].eq_ignore_ascii_case(n)
237 {
238 return Some(i);
239 }
240 i += 1;
241 }
242 None
243}
244
245#[must_use]
277pub fn minify_html(html: &str) -> String {
278 let bytes = html.as_bytes();
279 let mut out = String::with_capacity(html.len());
280 let mut i = 0usize;
281 let mut pending_space = false;
285
286 while i < bytes.len() {
287 if bytes[i] == b'<' {
288 if pending_space {
289 out.push(' ');
290 pending_space = false;
291 }
292 if html[i..].starts_with("<!--") {
293 let end =
294 html[i..].find("-->").map_or(bytes.len(), |p| i + p + 3);
295 out.push_str(&html[i..end]);
296 i = end;
297 continue;
298 }
299 let (tag_end, name) = scan_tag(html, i);
300 let self_closing = html[i..tag_end].trim_end().ends_with("/>");
301 out.push_str(&html[i..tag_end]);
302 i = tag_end;
303
304 if let Some(name) = name {
305 if RAW_TEXT_ELEMENTS.contains(&name.as_str()) && !self_closing {
306 let rest = &bytes[i..];
307 let stop =
308 find_closing_tag(rest, &name).unwrap_or(rest.len());
309 out.push_str(&html[i..i + stop]);
310 i += stop;
311 }
312 }
313 continue;
314 }
315
316 let ch = html[i..].chars().next().unwrap_or('\0');
317 if ch.is_whitespace() {
318 pending_space = true;
319 } else {
320 if pending_space {
321 out.push(' ');
322 pending_space = false;
323 }
324 out.push(ch);
325 }
326 i += ch.len_utf8();
327 }
328 if pending_space {
329 out.push(' ');
330 }
331 out
332}
333
334pub use crate::plugins_group::assets::{minify_css, minify_js};
342
343#[derive(Debug, Copy, Clone)]
358pub struct ImageOptiPlugin;
359
360impl Plugin for ImageOptiPlugin {
361 fn name(&self) -> &'static str {
362 "image-opti"
363 }
364
365 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
366 if !ctx.site_dir.exists() {
367 return Ok(());
368 }
369 let mut images = Vec::new();
370 for entry in fs::read_dir(&ctx.site_dir).with_path(&ctx.site_dir)? {
371 let entry = entry.with_path(&ctx.site_dir)?;
372 let path = entry.path();
373 if let Some(ext) = path.extension() {
374 let ext = ext.to_string_lossy().to_lowercase();
375 if matches!(
376 ext.as_str(),
377 "png" | "jpg" | "jpeg" | "gif" | "bmp"
378 ) {
379 images.push(path);
380 }
381 }
382 }
383 if !images.is_empty() {
384 println!(
385 "[image-opti] Found {} images for optimization",
386 images.len()
387 );
388 }
389 Ok(())
390 }
391}
392
393#[deprecated(
411 since = "0.0.58",
412 note = "use `ssg::deploy::DeployPlugin`; this one is never registered by the pipeline"
413)]
414#[derive(Debug)]
415pub struct DeployPlugin {
416 target: String,
417}
418
419#[allow(deprecated)]
420impl DeployPlugin {
421 #[must_use]
433 pub fn new(target: &str) -> Self {
434 Self {
435 target: target.to_string(),
436 }
437 }
438}
439
440#[allow(deprecated)]
441impl Plugin for DeployPlugin {
442 fn name(&self) -> &'static str {
443 "deploy"
444 }
445
446 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
447 println!(
448 "[deploy] Site at {} ready for deployment to '{}'",
449 ctx.site_dir.display(),
450 self.target
451 );
452 Ok(())
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 #![allow(deprecated)]
462 use super::*;
463 use crate::plugin::{PluginCache, PluginContext};
464 use crate::test_support::init_logger;
465 use anyhow::Result;
466 use std::path::Path;
467 use tempfile::tempdir;
468
469 fn test_ctx_with(site_dir: &Path) -> PluginContext {
470 init_logger();
471 PluginContext::new(
472 Path::new("content"),
473 Path::new("build"),
474 site_dir,
475 Path::new("templates"),
476 )
477 }
478
479 #[test]
480 fn test_minify_plugin_name() {
481 assert_eq!(MinifyPlugin.name(), "minify");
482 }
483
484 #[test]
485 fn test_minify_plugin_empty_dir() -> Result<()> {
486 let temp = tempdir().unwrap();
487 let ctx = test_ctx_with(temp.path());
488 MinifyPlugin.after_compile(&ctx).unwrap();
489 Ok(())
490 }
491
492 #[test]
493 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
494 fn test_minify_plugin_processes_html() -> Result<()> {
495 let temp = tempdir().unwrap();
496 let html_path = temp.path().join("index.html");
497 fs::write(&html_path, "<h1> Hello World </h1>").unwrap();
498
499 let ctx = test_ctx_with(temp.path());
500 MinifyPlugin.after_compile(&ctx).unwrap();
501
502 let content = fs::read_to_string(&html_path).unwrap();
503 assert!(!content.contains(" "));
504 Ok(())
505 }
506
507 #[test]
508 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
509 fn test_minify_plugin_cache_skips_unchanged_html() -> Result<()> {
510 let temp = tempdir().unwrap();
516 let html_path = temp.path().join("index.html");
517 fs::write(&html_path, "<h1> Hello World </h1>").unwrap();
518
519 let mut cache = PluginCache::new();
520 cache.update(&html_path);
521
522 let mut ctx = test_ctx_with(temp.path());
523 ctx.cache = Some(cache);
524 MinifyPlugin.after_compile(&ctx).unwrap();
525
526 let content = fs::read_to_string(&html_path).unwrap();
528 assert_eq!(content, "<h1> Hello World </h1>");
529 Ok(())
530 }
531
532 #[test]
533 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
534 fn test_minify_plugin_cache_processes_changed_html() -> Result<()> {
535 let temp = tempdir().unwrap();
539 let html_path = temp.path().join("index.html");
540 fs::write(&html_path, "<h1> Hello World </h1>").unwrap();
541
542 let mut cache = PluginCache::new();
543 fs::write(&html_path, "stale content").unwrap();
544 cache.update(&html_path);
545 fs::write(&html_path, "<h1> Hello World </h1>").unwrap();
546
547 let mut ctx = test_ctx_with(temp.path());
548 ctx.cache = Some(cache);
549 MinifyPlugin.after_compile(&ctx).unwrap();
550
551 let content = fs::read_to_string(&html_path).unwrap();
552 assert!(!content.contains(" "), "changed file must be minified");
553 Ok(())
554 }
555
556 #[test]
557 fn minify_plugin_minifies_css_too() -> Result<()> {
558 let temp = tempdir().unwrap();
559 let css_path = temp.path().join("style.css");
560 fs::write(&css_path, "body { color: red; }").unwrap();
561
562 let ctx = test_ctx_with(temp.path());
563 MinifyPlugin.after_compile(&ctx).unwrap();
564
565 let content = fs::read_to_string(&css_path).unwrap();
570 assert!(
571 !content.contains(" "),
572 "CSS was not minified: {content:?}"
573 );
574 assert!(content.contains("color"));
575 assert!(content.contains("red"));
576 Ok(())
577 }
578
579 #[test]
580 fn test_minify_plugin_nonexistent_dir() -> Result<()> {
581 let ctx = test_ctx_with(Path::new("/nonexistent"));
582 MinifyPlugin.after_compile(&ctx).unwrap();
583 Ok(())
584 }
585
586 #[test]
587 fn minify_html_leaves_raw_text_elements_byte_for_byte() {
588 for input in [
592 r#"<script>var s = "a b";</script>"#,
593 "<textarea>line1\n line2</textarea>",
594 r#"<style>a{content:"x y"}</style>"#,
595 "<pre> keep spaces </pre>",
596 ] {
597 assert_eq!(
598 minify_html(input),
599 input,
600 "raw text was rewritten: {input:?}"
601 );
602 }
603 }
604
605 #[test]
606 fn minify_html_minifies_around_a_pre_block() {
607 let out = minify_html("<p>a b</p><pre>x y</pre><p>c d</p>");
611 assert_eq!(out, "<p>a b</p><pre>x y</pre><p>c d</p>");
612 }
613
614 #[test]
615 fn minify_html_keeps_attribute_values_intact() {
616 let input = r#"<a title="two spaces" href="/x">t t</a>"#;
617 assert_eq!(
618 minify_html(input),
619 r#"<a title="two spaces" href="/x">t t</a>"#
620 );
621 }
622
623 #[test]
624 fn minify_html_does_not_end_a_tag_on_a_quoted_angle_bracket() {
625 let input = r#"<a title="a > b">x y</a>"#;
626 assert_eq!(minify_html(input), r#"<a title="a > b">x y</a>"#);
627 }
628
629 #[test]
630 fn minify_html_preserves_comments() {
631 let input = "<!--[if IE]> legacy <![endif]--><p>a b</p>";
632 assert_eq!(
633 minify_html(input),
634 "<!--[if IE]> legacy <![endif]--><p>a b</p>"
635 );
636 }
637
638 #[test]
639 fn minify_html_is_idempotent() {
640 let corpus = [
641 "<p> a b </p>",
642 r#"<script>var s = "a b";</script><p> c </p>"#,
643 "<pre> x </pre><div> y </div>",
644 "<!DOCTYPE html><html lang=\"en\"><body> hi </body></html>",
645 ];
646 for input in corpus {
647 let once = minify_html(input);
648 assert_eq!(minify_html(&once), once, "not idempotent: {input:?}");
649 }
650 }
651
652 #[test]
653 fn test_minify_html_collapses_whitespace() {
654 let result = minify_html("<p> Hello World </p>");
655 assert_eq!(result, "<p> Hello World </p>");
656 }
657
658 #[test]
659 fn test_minify_html_preserves_pre() {
660 let input = "<pre> keep spaces </pre>";
661 let result = minify_html(input);
662 assert_eq!(result, input);
663 }
664
665 #[test]
666 fn test_image_opti_plugin_name() {
667 assert_eq!(ImageOptiPlugin.name(), "image-opti");
668 }
669
670 #[test]
671 fn test_image_opti_plugin_finds_images() -> Result<()> {
672 let temp = tempdir().unwrap();
673 fs::write(temp.path().join("photo.png"), "PNG").unwrap();
674 fs::write(temp.path().join("logo.jpg"), "JPG").unwrap();
675 fs::write(temp.path().join("style.css"), "CSS").unwrap();
676
677 let ctx = test_ctx_with(temp.path());
678 ImageOptiPlugin.after_compile(&ctx).unwrap();
679 Ok(())
680 }
681
682 #[test]
683 fn test_image_opti_plugin_nonexistent_dir() -> Result<()> {
684 let ctx = test_ctx_with(Path::new("/nonexistent"));
685 ImageOptiPlugin.after_compile(&ctx).unwrap();
686 Ok(())
687 }
688
689 #[test]
690 fn test_deploy_plugin_name() {
691 let p = DeployPlugin::new("staging");
692 assert_eq!(p.name(), "deploy");
693 }
694
695 #[test]
696 fn test_deploy_plugin_prints_target() -> Result<()> {
697 let temp = tempdir().unwrap();
698 let ctx = test_ctx_with(temp.path());
699 let p = DeployPlugin::new("production");
700 p.after_compile(&ctx).unwrap();
701 Ok(())
702 }
703
704 #[test]
705 fn test_all_plugins_register() {
706 use crate::plugin::PluginManager;
707 let mut pm = PluginManager::new();
708 pm.register(MinifyPlugin);
709 pm.register(ImageOptiPlugin);
710 pm.register(DeployPlugin::new("test"));
711 assert_eq!(pm.len(), 3);
712 assert_eq!(pm.names(), vec!["minify", "image-opti", "deploy"]);
713 }
714
715 #[test]
716 fn minify_plugin_preserves_pre_blocks() {
717 let input = "<pre> code with spaces </pre><p> other </p>";
719
720 let result = minify_html(input);
722
723 assert_eq!(result, "<pre> code with spaces </pre><p> other </p>");
725 }
726
727 #[test]
728 fn minify_plugin_handles_nested_html() {
729 let input = "<div> <section> <article> <p> deep </p> </article> </section> </div>";
731
732 let result = minify_html(input);
734
735 assert!(!result.contains(" "));
737 assert!(result.contains("<div>"));
738 assert!(result.contains("</div>"));
739 assert!(result.contains("deep"));
740 }
741
742 #[test]
743 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
744 fn minify_plugin_empty_html_file() -> Result<()> {
745 let temp = tempdir().unwrap();
747 let html_path = temp.path().join("empty.html");
748 fs::write(&html_path, "").unwrap();
749
750 let ctx = test_ctx_with(temp.path());
752 MinifyPlugin.after_compile(&ctx).unwrap();
753
754 let content = fs::read_to_string(&html_path).unwrap();
756 assert!(content.is_empty(), "{:?}", content);
757 Ok(())
758 }
759
760 #[test]
761 fn image_opti_plugin_finds_jpeg_variants() -> Result<()> {
762 let temp = tempdir().unwrap();
764 fs::write(temp.path().join("photo.jpg"), "JPG").unwrap();
765 fs::write(temp.path().join("banner.jpeg"), "JPEG").unwrap();
766 fs::write(temp.path().join("readme.txt"), "text").unwrap();
767 fs::write(temp.path().join("LICENSE"), "MIT").unwrap();
770
771 let ctx = test_ctx_with(temp.path());
773 ImageOptiPlugin.after_compile(&ctx).unwrap();
774
775 let mut found = Vec::new();
778 for entry in fs::read_dir(temp.path()).unwrap() {
779 let path = entry.unwrap().path();
780 if let Some(ext) = path.extension() {
781 let ext = ext.to_string_lossy().to_lowercase();
782 if matches!(ext.as_str(), "jpg" | "jpeg") {
783 found.push(path);
784 }
785 }
786 }
787 assert_eq!(found.len(), 2);
788 Ok(())
789 }
790
791 #[test]
792 fn image_opti_plugin_nested_directories() -> Result<()> {
793 let temp = tempdir().unwrap();
795 let subdir = temp.path().join("subdir");
796 fs::create_dir(&subdir).unwrap();
797 fs::write(subdir.join("deep.png"), "PNG").unwrap();
798 fs::write(temp.path().join("top.png"), "PNG").unwrap();
799
800 let ctx = test_ctx_with(temp.path());
802 ImageOptiPlugin.after_compile(&ctx).unwrap();
803
804 Ok(())
807 }
808
809 #[test]
810 fn deploy_plugin_custom_target() -> Result<()> {
811 let temp = tempdir().unwrap();
813 let ctx = test_ctx_with(temp.path());
814 let target_name = "staging-eu-west-1";
815 let plugin = DeployPlugin::new(target_name);
816
817 plugin.after_compile(&ctx).unwrap();
819
820 assert_eq!(plugin.target, target_name);
822 Ok(())
823 }
824
825 #[test]
826 fn minify_plugin_nonexistent_dir_returns_ok() -> Result<()> {
827 let ctx = test_ctx_with(Path::new("/this/path/does/not/exist/at/all"));
829
830 assert!(MinifyPlugin.after_compile(&ctx).is_ok());
832 Ok(())
833 }
834
835 #[test]
840 fn minify_html_empty_string() {
841 let result = minify_html("");
842 assert_eq!(result, "");
843 }
844
845 #[test]
846 fn minify_html_whitespace_only() {
847 let result = minify_html(" \n\t \n ");
848 assert_eq!(result, " ");
849 }
850
851 #[test]
852 fn minify_html_no_whitespace() {
853 let input = "<p>hello</p>";
854 let result = minify_html(input);
855 assert_eq!(result, input);
856 }
857
858 #[test]
859 fn minify_html_preserves_pre_with_class() {
860 let input = "<pre class=\"lang-rust\"> fn main() { } </pre>";
861 let result = minify_html(input);
862 assert_eq!(result, input);
863 }
864
865 #[test]
866 fn minify_html_tabs_and_newlines() {
867 let input = "<div>\n\t<p>\n\t\tHello\n\t</p>\n</div>";
868 let result = minify_html(input);
869 assert_eq!(result, "<div> <p> Hello </p> </div>");
870 }
871
872 #[test]
873 fn minify_html_mixed_whitespace_types() {
874 let input = "<span> \t\n word \t\n </span>";
875 let result = minify_html(input);
876 assert_eq!(result, "<span> word </span>");
877 }
878
879 #[test]
880 fn minify_html_single_char() {
881 assert_eq!(minify_html("a"), "a");
882 assert_eq!(minify_html(" "), " ");
883 }
884
885 #[test]
886 fn minify_html_multiple_pre_tags() {
887 let input = "<pre>a</pre><pre>b</pre>";
888 let result = minify_html(input);
889 assert_eq!(result, input);
890 }
891
892 #[test]
897 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
898 fn minify_plugin_processes_multiple_html_files() -> Result<()> {
899 let temp = tempdir().unwrap();
900 fs::write(temp.path().join("a.html"), "<p> hello </p>").unwrap();
901 fs::write(temp.path().join("b.html"), "<div> world </div>").unwrap();
902 fs::write(temp.path().join("c.txt"), " not html ").unwrap();
903
904 let ctx = test_ctx_with(temp.path());
905 MinifyPlugin.after_compile(&ctx).unwrap();
906
907 let a = fs::read_to_string(temp.path().join("a.html")).unwrap();
908 let b = fs::read_to_string(temp.path().join("b.html")).unwrap();
909 let c = fs::read_to_string(temp.path().join("c.txt")).unwrap();
910
911 assert!(!a.contains(" "), "a.html should be minified");
912 assert!(!b.contains(" "), "b.html should be minified");
913 assert!(c.contains(" "), "c.txt should not be minified");
914 Ok(())
915 }
916
917 #[test]
918 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
919 fn minify_plugin_whitespace_only_html_file() -> Result<()> {
920 let temp = tempdir().unwrap();
921 fs::write(temp.path().join("ws.html"), " \n\t \n ").unwrap();
922
923 let ctx = test_ctx_with(temp.path());
924 MinifyPlugin.after_compile(&ctx).unwrap();
925
926 let content = fs::read_to_string(temp.path().join("ws.html")).unwrap();
927 assert_eq!(content, " ");
928 Ok(())
929 }
930
931 #[test]
932 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
933 fn minify_plugin_keeps_pre_and_minifies_the_rest() -> Result<()> {
934 let temp = tempdir().unwrap();
935 let original =
936 "<html><pre> keep spaces </pre><p> other </p></html>";
937 fs::write(temp.path().join("pre.html"), original).unwrap();
938
939 let ctx = test_ctx_with(temp.path());
940 MinifyPlugin.after_compile(&ctx).unwrap();
941
942 let content = fs::read_to_string(temp.path().join("pre.html")).unwrap();
947 assert_eq!(
948 content,
949 "<html><pre> keep spaces </pre><p> other </p></html>"
950 );
951 Ok(())
952 }
953
954 #[test]
959 fn image_opti_plugin_finds_gif_and_bmp() -> Result<()> {
960 let temp = tempdir().unwrap();
961 fs::write(temp.path().join("anim.gif"), "GIF").unwrap();
962 fs::write(temp.path().join("icon.bmp"), "BMP").unwrap();
963 fs::write(temp.path().join("doc.pdf"), "PDF").unwrap();
964 fs::write(temp.path().join("Makefile"), "all:").unwrap();
967
968 let ctx = test_ctx_with(temp.path());
969 ImageOptiPlugin.after_compile(&ctx).unwrap();
970
971 let mut count = 0;
975 for entry in fs::read_dir(temp.path()).unwrap() {
976 let path = entry.unwrap().path();
977 if let Some(ext) = path.extension() {
978 let ext = ext.to_string_lossy().to_lowercase();
979 if matches!(ext.as_str(), "gif" | "bmp") {
980 count += 1;
981 }
982 }
983 }
984 assert_eq!(count, 2);
985 Ok(())
986 }
987
988 #[test]
989 fn image_opti_plugin_empty_dir_no_crash() -> Result<()> {
990 let temp = tempdir().unwrap();
991 let ctx = test_ctx_with(temp.path());
992 ImageOptiPlugin.after_compile(&ctx).unwrap();
993 Ok(())
994 }
995
996 #[test]
997 fn image_opti_plugin_no_images() -> Result<()> {
998 let temp = tempdir().unwrap();
999 fs::write(temp.path().join("readme.txt"), "text").unwrap();
1000 fs::write(temp.path().join("style.css"), "css").unwrap();
1001
1002 let ctx = test_ctx_with(temp.path());
1003 ImageOptiPlugin.after_compile(&ctx).unwrap();
1004 Ok(())
1005 }
1006
1007 #[test]
1008 fn image_opti_plugin_files_without_extension() -> Result<()> {
1009 let temp = tempdir().unwrap();
1010 fs::write(temp.path().join("Makefile"), "all:").unwrap();
1011 fs::write(temp.path().join("LICENSE"), "MIT").unwrap();
1012
1013 let ctx = test_ctx_with(temp.path());
1014 ImageOptiPlugin.after_compile(&ctx).unwrap();
1015 Ok(())
1016 }
1017
1018 #[test]
1023 fn deploy_plugin_empty_target() -> Result<()> {
1024 let temp = tempdir().unwrap();
1025 let ctx = test_ctx_with(temp.path());
1026 let plugin = DeployPlugin::new("");
1027 plugin.after_compile(&ctx).unwrap();
1028 assert_eq!(plugin.target, "");
1029 Ok(())
1030 }
1031
1032 #[test]
1033 fn deploy_plugin_various_targets() -> Result<()> {
1034 let temp = tempdir().unwrap();
1035 let ctx = test_ctx_with(temp.path());
1036
1037 for target in ["staging", "production", "preview", "canary"] {
1038 let plugin = DeployPlugin::new(target);
1039 assert_eq!(plugin.name(), "deploy");
1040 assert_eq!(plugin.target, target);
1041 plugin.after_compile(&ctx).unwrap();
1042 }
1043 Ok(())
1044 }
1045
1046 #[test]
1047 fn deploy_plugin_debug_format() {
1048 let plugin = DeployPlugin::new("prod");
1049 let debug = format!("{plugin:?}");
1050 assert!(debug.contains("prod"));
1051 }
1052
1053 #[test]
1058 fn minify_plugin_copy_clone() {
1059 let a = MinifyPlugin;
1060 let b = a;
1061 #[allow(clippy::clone_on_copy)]
1064 let c = a.clone();
1065 assert_eq!(a.name(), b.name());
1066 assert_eq!(a.name(), c.name());
1067 }
1068
1069 #[test]
1070 fn minify_plugin_debug_format() {
1071 let debug = format!("{:?}", MinifyPlugin);
1072 assert!(debug.contains("MinifyPlugin"));
1073 }
1074
1075 #[test]
1076 fn image_opti_plugin_copy_clone() {
1077 let a = ImageOptiPlugin;
1078 let b = a;
1079 #[allow(clippy::clone_on_copy)]
1082 let c = a.clone();
1083 assert_eq!(a.name(), b.name());
1084 assert_eq!(a.name(), c.name());
1085 }
1086
1087 #[test]
1088 fn image_opti_plugin_debug_format() {
1089 let debug = format!("{:?}", ImageOptiPlugin);
1090 assert!(debug.contains("ImageOptiPlugin"));
1091 }
1092
1093 #[test]
1094 fn test_minify_plugin_read_dir_error() {
1095 let temp = tempdir().unwrap();
1096 let file_path = temp.path().join("not_a_dir");
1097 fs::write(&file_path, "").unwrap();
1098 let ctx = test_ctx_with(&file_path);
1099 let res = MinifyPlugin.after_compile(&ctx);
1100 assert!(res.is_err());
1101 }
1102
1103 #[test]
1104 fn test_image_opti_plugin_read_dir_error() {
1105 let temp = tempdir().unwrap();
1106 let file_path = temp.path().join("not_a_dir");
1107 fs::write(&file_path, "").unwrap();
1108 let ctx = test_ctx_with(&file_path);
1109 let res = ImageOptiPlugin.after_compile(&ctx);
1110 assert!(res.is_err());
1111 }
1112
1113 #[test]
1118 fn minify_html_preserves_pre_content_bit_identical() {
1119 let body = "fn main() {\n println!(\"hi\");\n}";
1120 let input =
1121 format!("<html><body><pre><code>{body}</code></pre></body></html>");
1122 let out = minify_html(&input);
1123 assert!(
1127 out.contains(body),
1128 "minified output must preserve <pre> body byte-for-byte:\n{out}"
1129 );
1130 }
1131
1132 #[test]
1133 fn minify_css_compresses_input() {
1134 let input =
1135 "body {\n color: red;\n margin: 0px 0px 0px 0px;\n}";
1136 let out = minify_css(input);
1137 assert!(out.len() < input.len());
1138 assert!(out.contains("red"));
1139 }
1140
1141 #[test]
1142 fn minify_js_compresses_input() {
1143 let input = "const greeting = 'hello world';\nconsole.log(greeting);";
1144 let out = minify_js(input);
1145 assert!(out.len() < input.len());
1146 }
1147
1148 #[test]
1149 fn minify_plugin_recursive_walk_processes_nested_html() -> Result<()> {
1150 let temp = tempdir().unwrap();
1151 let deep = temp.path().join("blog").join("2026").join("post");
1152 fs::create_dir_all(&deep).unwrap();
1153 let nested = deep.join("index.html");
1154 fs::write(
1155 &nested,
1156 "<html> <body> <p> nested </p> </body> </html>",
1157 )
1158 .unwrap();
1159 let top = temp.path().join("index.html");
1160 fs::write(&top, "<html> <body> <p> top </p> </body></html>")
1161 .unwrap();
1162
1163 let ctx = test_ctx_with(temp.path());
1164 MinifyPlugin.after_compile(&ctx).unwrap();
1165
1166 let nested_after = fs::read_to_string(&nested).unwrap();
1167 assert!(
1169 nested_after.len()
1170 < "<html> <body> <p> nested </p> </body> </html>"
1171 .len(),
1172 "nested file should have been minified: {nested_after}"
1173 );
1174 Ok(())
1175 }
1176
1177 #[test]
1182 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
1183 fn minify_plugin_read_failure_on_invalid_utf8_html() {
1184 let temp = tempdir().unwrap();
1185 fs::write(temp.path().join("broken.html"), [0xFF, 0xFE, 0xFD]).unwrap();
1187
1188 let ctx = test_ctx_with(temp.path());
1189 let err = MinifyPlugin
1190 .after_compile(&ctx)
1191 .expect_err("invalid UTF-8 html must surface a read error");
1192 let msg = format!("{err:?}");
1193 assert!(msg.contains("broken.html"), "path context expected: {msg}");
1194 }
1195
1196 #[test]
1197 #[cfg(unix)]
1198 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
1199 fn minify_plugin_write_failure_on_readonly_html() {
1200 use std::os::unix::fs::PermissionsExt;
1201
1202 let temp = tempdir().unwrap();
1203 let file = temp.path().join("locked.html");
1204 fs::write(&file, "<p> spaced out </p>").unwrap();
1205 fs::set_permissions(&file, fs::Permissions::from_mode(0o444)).unwrap();
1206
1207 let ctx = test_ctx_with(temp.path());
1208 let result = MinifyPlugin.after_compile(&ctx);
1209 fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
1210 let err =
1211 result.expect_err("write to a read-only html file must surface");
1212 let msg = format!("{err:?}");
1213 assert!(msg.contains("locked.html"), "path context expected: {msg}");
1214 }
1215}
1216
1217#[cfg(all(test, feature = "test-fault-injection"))]
1218mod fault_tests {
1219 use super::*;
1220 use crate::plugin::PluginContext;
1221 use serial_test::serial;
1222 use tempfile::tempdir;
1223
1224 struct FailGuard(&'static str);
1227
1228 impl Drop for FailGuard {
1229 fn drop(&mut self) {
1230 let _ = fail::cfg(self.0, "off");
1231 }
1232 }
1233
1234 #[test]
1235 #[serial]
1236 fn minify_read_failpoint_propagates() {
1237 let _guard = FailGuard("plugins::minify-read");
1238 fail::cfg("plugins::minify-read", "return")
1239 .expect("activate failpoint");
1240
1241 let dir = tempdir().unwrap();
1242 fs::write(dir.path().join("index.html"), "<p>x</p>").unwrap();
1243
1244 let ctx =
1245 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
1246 let err = MinifyPlugin
1247 .after_compile(&ctx)
1248 .expect_err("injected read failure must propagate");
1249 assert!(format!("{err:?}").contains("injected: plugins::minify-read"));
1250 }
1251
1252 #[test]
1253 #[serial]
1254 fn minify_write_failpoint_propagates() {
1255 let _guard = FailGuard("plugins::minify-write");
1256 fail::cfg("plugins::minify-write", "return")
1257 .expect("activate failpoint");
1258
1259 let dir = tempdir().unwrap();
1260 fs::write(dir.path().join("index.html"), "<p>x</p>").unwrap();
1261
1262 let ctx =
1263 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
1264 let err = MinifyPlugin
1265 .after_compile(&ctx)
1266 .expect_err("injected write failure must propagate");
1267 assert!(format!("{err:?}").contains("injected: plugins::minify-write"));
1268 }
1269}