1use crate::error::{PathErrorExt, SsgError};
15use crate::plugin::{Plugin, PluginContext};
16use rayon::prelude::*;
17use std::fs;
18use std::path::PathBuf;
19use std::sync::atomic::{AtomicUsize, Ordering};
20
21#[derive(Debug, Copy, Clone)]
46pub struct MinifyPlugin;
47
48impl Plugin for MinifyPlugin {
49 fn name(&self) -> &'static str {
50 "minify"
51 }
52
53 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
54 if !ctx.site_dir.exists() {
55 return Ok(());
56 }
57
58 let cache = ctx.cache.as_ref();
59 let (html_files, css_files, js_files) =
60 collect_minifiable_files(&ctx.site_dir, cache)?;
61
62 let count = AtomicUsize::new(0);
63
64 html_files
65 .par_iter()
66 .try_for_each(|path| -> Result<(), SsgError> {
67 fail_point!("plugins::minify-read", |_| {
68 Err(SsgError::Io {
69 path: path.clone(),
70 source: std::io::Error::other(
71 "injected: plugins::minify-read",
72 ),
73 })
74 });
75 let content = fs::read_to_string(path).with_path(path)?;
76 let minified = minify_html(&content);
77 fail_point!("plugins::minify-write", |_| {
78 Err(SsgError::Io {
79 path: path.clone(),
80 source: std::io::Error::other(
81 "injected: plugins::minify-write",
82 ),
83 })
84 });
85 fs::write(path, &minified).with_path(path)?;
86 let _ = count.fetch_add(1, Ordering::Relaxed);
87 Ok(())
88 })?;
89
90 css_files
96 .par_iter()
97 .try_for_each(|path| -> Result<(), SsgError> {
98 let content = fs::read_to_string(path).with_path(path)?;
99 let minified = minify_css(&content);
100 fs::write(path, &minified).with_path(path)?;
101 let _ = count.fetch_add(1, Ordering::Relaxed);
102 Ok(())
103 })?;
104
105 js_files
106 .par_iter()
107 .try_for_each(|path| -> Result<(), SsgError> {
108 let content = fs::read_to_string(path).with_path(path)?;
109 let minified = minify_js(&content);
110 fs::write(path, &minified).with_path(path)?;
111 let _ = count.fetch_add(1, Ordering::Relaxed);
112 Ok(())
113 })?;
114
115 let total = count.load(Ordering::Relaxed);
116 if total > 0 {
117 println!("[minify] Processed {total} file(s)");
118 }
119 Ok(())
120 }
121}
122
123type MinifiableFiles = (Vec<PathBuf>, Vec<PathBuf>, Vec<PathBuf>);
125
126fn collect_minifiable_files(
135 site_dir: &std::path::Path,
136 cache: Option<&crate::plugin::PluginCache>,
137) -> Result<MinifiableFiles, SsgError> {
138 let mut html = Vec::new();
139 let mut css = Vec::new();
140 let mut js = Vec::new();
141
142 drop(fs::read_dir(site_dir).with_path(site_dir)?);
146
147 let mut stack = vec![site_dir.to_path_buf()];
148 while let Some(dir) = stack.pop() {
149 let Ok(entries) = fs::read_dir(&dir) else {
150 continue;
151 };
152 for entry in entries.filter_map(Result::ok) {
153 let Ok(file_type) = entry.file_type() else {
154 continue;
155 };
156 if file_type.is_symlink() {
157 continue;
158 }
159 let path = entry.path();
160 if file_type.is_dir() {
161 stack.push(path);
162 continue;
163 }
164 if !file_type.is_file() {
165 continue;
166 }
167 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
168 continue;
169 };
170 if cache.is_some_and(|c| !c.has_changed(&path)) {
172 continue;
173 }
174 match ext {
175 "html" => html.push(path),
176 "css" => css.push(path),
177 "js" => js.push(path),
178 _ => {}
179 }
180 }
181 }
182 Ok((html, css, js))
183}
184
185const RAW_TEXT_ELEMENTS: [&str; 4] = ["pre", "textarea", "script", "style"];
211
212fn scan_tag(html: &str, start: usize) -> (usize, Option<String>) {
218 let bytes = html.as_bytes();
219 let mut i = start + 1;
220 let closing = bytes.get(i) == Some(&b'/');
221 if closing {
222 i += 1;
223 }
224 let name_start = i;
225 while i < bytes.len()
226 && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-')
227 {
228 i += 1;
229 }
230 let name = if i > name_start && !closing {
231 Some(html[name_start..i].to_ascii_lowercase())
232 } else {
233 None
234 };
235 let mut quote: Option<u8> = None;
236 while i < bytes.len() {
237 let c = bytes[i];
238 match quote {
239 Some(q) => {
240 if c == q {
241 quote = None;
242 }
243 }
244 None => {
245 if c == b'"' || c == b'\'' {
246 quote = Some(c);
247 } else if c == b'>' {
248 return (i + 1, name);
249 }
250 }
251 }
252 i += 1;
253 }
254 (bytes.len(), name)
255}
256
257fn find_closing_tag(hay: &[u8], name: &str) -> Option<usize> {
259 let n = name.as_bytes();
260 let mut i = 0;
261 while i + 2 + n.len() <= hay.len() {
262 if hay[i] == b'<'
263 && hay[i + 1] == b'/'
264 && hay[i + 2..i + 2 + n.len()].eq_ignore_ascii_case(n)
265 {
266 return Some(i);
267 }
268 i += 1;
269 }
270 None
271}
272
273#[must_use]
305pub fn minify_html(html: &str) -> String {
306 let bytes = html.as_bytes();
307 let mut out = String::with_capacity(html.len());
308 let mut i = 0usize;
309 let mut pending_space = false;
313
314 while i < bytes.len() {
315 if bytes[i] == b'<' {
316 if pending_space {
317 out.push(' ');
318 pending_space = false;
319 }
320 if html[i..].starts_with("<!--") {
321 let end =
322 html[i..].find("-->").map_or(bytes.len(), |p| i + p + 3);
323 out.push_str(&html[i..end]);
324 i = end;
325 continue;
326 }
327 let (tag_end, name) = scan_tag(html, i);
328 let self_closing = html[i..tag_end].trim_end().ends_with("/>");
329 out.push_str(&html[i..tag_end]);
330 i = tag_end;
331
332 if let Some(name) = name {
333 if RAW_TEXT_ELEMENTS.contains(&name.as_str()) && !self_closing {
334 let rest = &bytes[i..];
335 let stop =
336 find_closing_tag(rest, &name).unwrap_or(rest.len());
337 out.push_str(&html[i..i + stop]);
338 i += stop;
339 }
340 }
341 continue;
342 }
343
344 let ch = html[i..].chars().next().unwrap_or('\0');
345 if ch.is_whitespace() {
346 pending_space = true;
347 } else {
348 if pending_space {
349 out.push(' ');
350 pending_space = false;
351 }
352 out.push(ch);
353 }
354 i += ch.len_utf8();
355 }
356 if pending_space {
357 out.push(' ');
358 }
359 out
360}
361
362pub use crate::plugins_group::assets::{minify_css, minify_js};
370
371#[derive(Debug, Copy, Clone)]
386pub struct ImageOptiPlugin;
387
388impl Plugin for ImageOptiPlugin {
389 fn name(&self) -> &'static str {
390 "image-opti"
391 }
392
393 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
394 if !ctx.site_dir.exists() {
395 return Ok(());
396 }
397 let mut images = Vec::new();
398 for entry in fs::read_dir(&ctx.site_dir).with_path(&ctx.site_dir)? {
399 let entry = entry.with_path(&ctx.site_dir)?;
400 let path = entry.path();
401 if let Some(ext) = path.extension() {
402 let ext = ext.to_string_lossy().to_lowercase();
403 if matches!(
404 ext.as_str(),
405 "png" | "jpg" | "jpeg" | "gif" | "bmp"
406 ) {
407 images.push(path);
408 }
409 }
410 }
411 if !images.is_empty() {
412 println!(
413 "[image-opti] Found {} images for optimization",
414 images.len()
415 );
416 }
417 Ok(())
418 }
419}
420
421#[deprecated(
439 since = "0.0.58",
440 note = "use `ssg::deploy::DeployPlugin`; this one is never registered by the pipeline"
441)]
442#[derive(Debug)]
443pub struct DeployPlugin {
444 target: String,
445}
446
447#[allow(deprecated)]
448impl DeployPlugin {
449 #[must_use]
461 pub fn new(target: &str) -> Self {
462 Self {
463 target: target.to_string(),
464 }
465 }
466}
467
468#[allow(deprecated)]
469impl Plugin for DeployPlugin {
470 fn name(&self) -> &'static str {
471 "deploy"
472 }
473
474 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
475 println!(
476 "[deploy] Site at {} ready for deployment to '{}'",
477 ctx.site_dir.display(),
478 self.target
479 );
480 Ok(())
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 #![allow(deprecated)]
490 use super::*;
491 use crate::plugin::{PluginCache, PluginContext};
492 use crate::test_support::init_logger;
493 use anyhow::Result;
494 use std::path::Path;
495 use tempfile::tempdir;
496
497 fn test_ctx_with(site_dir: &Path) -> PluginContext {
498 init_logger();
499 PluginContext::new(
500 Path::new("content"),
501 Path::new("build"),
502 site_dir,
503 Path::new("templates"),
504 )
505 }
506
507 #[test]
508 fn test_minify_plugin_name() {
509 assert_eq!(MinifyPlugin.name(), "minify");
510 }
511
512 #[test]
513 fn test_minify_plugin_empty_dir() -> Result<()> {
514 let temp = tempdir().unwrap();
515 let ctx = test_ctx_with(temp.path());
516 MinifyPlugin.after_compile(&ctx).unwrap();
517 Ok(())
518 }
519
520 #[test]
521 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
522 fn test_minify_plugin_processes_html() -> Result<()> {
523 let temp = tempdir().unwrap();
524 let html_path = temp.path().join("index.html");
525 fs::write(&html_path, "<h1> Hello World </h1>").unwrap();
526
527 let ctx = test_ctx_with(temp.path());
528 MinifyPlugin.after_compile(&ctx).unwrap();
529
530 let content = fs::read_to_string(&html_path).unwrap();
531 assert!(!content.contains(" "));
532 Ok(())
533 }
534
535 #[test]
536 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
537 fn test_minify_plugin_cache_skips_unchanged_html() -> Result<()> {
538 let temp = tempdir().unwrap();
544 let html_path = temp.path().join("index.html");
545 fs::write(&html_path, "<h1> Hello World </h1>").unwrap();
546
547 let mut cache = PluginCache::new();
548 cache.update(&html_path);
549
550 let mut ctx = test_ctx_with(temp.path());
551 ctx.cache = Some(cache);
552 MinifyPlugin.after_compile(&ctx).unwrap();
553
554 let content = fs::read_to_string(&html_path).unwrap();
556 assert_eq!(content, "<h1> Hello World </h1>");
557 Ok(())
558 }
559
560 #[test]
561 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
562 fn test_minify_plugin_cache_processes_changed_html() -> Result<()> {
563 let temp = tempdir().unwrap();
567 let html_path = temp.path().join("index.html");
568 fs::write(&html_path, "<h1> Hello World </h1>").unwrap();
569
570 let mut cache = PluginCache::new();
571 fs::write(&html_path, "stale content").unwrap();
572 cache.update(&html_path);
573 fs::write(&html_path, "<h1> Hello World </h1>").unwrap();
574
575 let mut ctx = test_ctx_with(temp.path());
576 ctx.cache = Some(cache);
577 MinifyPlugin.after_compile(&ctx).unwrap();
578
579 let content = fs::read_to_string(&html_path).unwrap();
580 assert!(!content.contains(" "), "changed file must be minified");
581 Ok(())
582 }
583
584 #[test]
585 fn minify_plugin_minifies_css_too() -> Result<()> {
586 let temp = tempdir().unwrap();
587 let css_path = temp.path().join("style.css");
588 fs::write(&css_path, "body { color: red; }").unwrap();
589
590 let ctx = test_ctx_with(temp.path());
591 MinifyPlugin.after_compile(&ctx).unwrap();
592
593 let content = fs::read_to_string(&css_path).unwrap();
598 assert!(
599 !content.contains(" "),
600 "CSS was not minified: {content:?}"
601 );
602 assert!(content.contains("color"));
603 assert!(content.contains("red"));
604 Ok(())
605 }
606
607 #[test]
608 fn test_minify_plugin_nonexistent_dir() -> Result<()> {
609 let ctx = test_ctx_with(Path::new("/nonexistent"));
610 MinifyPlugin.after_compile(&ctx).unwrap();
611 Ok(())
612 }
613
614 #[test]
615 fn minify_html_leaves_raw_text_elements_byte_for_byte() {
616 for input in [
620 r#"<script>var s = "a b";</script>"#,
621 "<textarea>line1\n line2</textarea>",
622 r#"<style>a{content:"x y"}</style>"#,
623 "<pre> keep spaces </pre>",
624 ] {
625 assert_eq!(
626 minify_html(input),
627 input,
628 "raw text was rewritten: {input:?}"
629 );
630 }
631 }
632
633 #[test]
634 fn minify_html_minifies_around_a_pre_block() {
635 let out = minify_html("<p>a b</p><pre>x y</pre><p>c d</p>");
639 assert_eq!(out, "<p>a b</p><pre>x y</pre><p>c d</p>");
640 }
641
642 #[test]
643 fn minify_html_keeps_attribute_values_intact() {
644 let input = r#"<a title="two spaces" href="/x">t t</a>"#;
645 assert_eq!(
646 minify_html(input),
647 r#"<a title="two spaces" href="/x">t t</a>"#
648 );
649 }
650
651 #[test]
652 fn minify_html_does_not_end_a_tag_on_a_quoted_angle_bracket() {
653 let input = r#"<a title="a > b">x y</a>"#;
654 assert_eq!(minify_html(input), r#"<a title="a > b">x y</a>"#);
655 }
656
657 #[test]
658 fn minify_html_preserves_comments() {
659 let input = "<!--[if IE]> legacy <![endif]--><p>a b</p>";
660 assert_eq!(
661 minify_html(input),
662 "<!--[if IE]> legacy <![endif]--><p>a b</p>"
663 );
664 }
665
666 #[test]
667 fn minify_html_is_idempotent() {
668 let corpus = [
669 "<p> a b </p>",
670 r#"<script>var s = "a b";</script><p> c </p>"#,
671 "<pre> x </pre><div> y </div>",
672 "<!DOCTYPE html><html lang=\"en\"><body> hi </body></html>",
673 ];
674 for input in corpus {
675 let once = minify_html(input);
676 assert_eq!(minify_html(&once), once, "not idempotent: {input:?}");
677 }
678 }
679
680 #[test]
681 fn test_minify_html_collapses_whitespace() {
682 let result = minify_html("<p> Hello World </p>");
683 assert_eq!(result, "<p> Hello World </p>");
684 }
685
686 #[test]
687 fn test_minify_html_preserves_pre() {
688 let input = "<pre> keep spaces </pre>";
689 let result = minify_html(input);
690 assert_eq!(result, input);
691 }
692
693 #[test]
694 fn test_image_opti_plugin_name() {
695 assert_eq!(ImageOptiPlugin.name(), "image-opti");
696 }
697
698 #[test]
699 fn test_image_opti_plugin_finds_images() -> Result<()> {
700 let temp = tempdir().unwrap();
701 fs::write(temp.path().join("photo.png"), "PNG").unwrap();
702 fs::write(temp.path().join("logo.jpg"), "JPG").unwrap();
703 fs::write(temp.path().join("style.css"), "CSS").unwrap();
704
705 let ctx = test_ctx_with(temp.path());
706 ImageOptiPlugin.after_compile(&ctx).unwrap();
707 Ok(())
708 }
709
710 #[test]
711 fn test_image_opti_plugin_nonexistent_dir() -> Result<()> {
712 let ctx = test_ctx_with(Path::new("/nonexistent"));
713 ImageOptiPlugin.after_compile(&ctx).unwrap();
714 Ok(())
715 }
716
717 #[test]
718 fn test_deploy_plugin_name() {
719 let p = DeployPlugin::new("staging");
720 assert_eq!(p.name(), "deploy");
721 }
722
723 #[test]
724 fn test_deploy_plugin_prints_target() -> Result<()> {
725 let temp = tempdir().unwrap();
726 let ctx = test_ctx_with(temp.path());
727 let p = DeployPlugin::new("production");
728 p.after_compile(&ctx).unwrap();
729 Ok(())
730 }
731
732 #[test]
733 fn test_all_plugins_register() {
734 use crate::plugin::PluginManager;
735 let mut pm = PluginManager::new();
736 pm.register(MinifyPlugin);
737 pm.register(ImageOptiPlugin);
738 pm.register(DeployPlugin::new("test"));
739 assert_eq!(pm.len(), 3);
740 assert_eq!(pm.names(), vec!["minify", "image-opti", "deploy"]);
741 }
742
743 #[test]
744 fn minify_plugin_preserves_pre_blocks() {
745 let input = "<pre> code with spaces </pre><p> other </p>";
747
748 let result = minify_html(input);
750
751 assert_eq!(result, "<pre> code with spaces </pre><p> other </p>");
753 }
754
755 #[test]
756 fn minify_plugin_handles_nested_html() {
757 let input = "<div> <section> <article> <p> deep </p> </article> </section> </div>";
759
760 let result = minify_html(input);
762
763 assert!(!result.contains(" "));
765 assert!(result.contains("<div>"));
766 assert!(result.contains("</div>"));
767 assert!(result.contains("deep"));
768 }
769
770 #[test]
771 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
772 fn minify_plugin_empty_html_file() -> Result<()> {
773 let temp = tempdir().unwrap();
775 let html_path = temp.path().join("empty.html");
776 fs::write(&html_path, "").unwrap();
777
778 let ctx = test_ctx_with(temp.path());
780 MinifyPlugin.after_compile(&ctx).unwrap();
781
782 let content = fs::read_to_string(&html_path).unwrap();
784 assert!(content.is_empty());
785 Ok(())
786 }
787
788 #[test]
789 fn image_opti_plugin_finds_jpeg_variants() -> Result<()> {
790 let temp = tempdir().unwrap();
792 fs::write(temp.path().join("photo.jpg"), "JPG").unwrap();
793 fs::write(temp.path().join("banner.jpeg"), "JPEG").unwrap();
794 fs::write(temp.path().join("readme.txt"), "text").unwrap();
795 fs::write(temp.path().join("LICENSE"), "MIT").unwrap();
798
799 let ctx = test_ctx_with(temp.path());
801 ImageOptiPlugin.after_compile(&ctx).unwrap();
802
803 let mut found = Vec::new();
806 for entry in fs::read_dir(temp.path()).unwrap() {
807 let path = entry.unwrap().path();
808 if let Some(ext) = path.extension() {
809 let ext = ext.to_string_lossy().to_lowercase();
810 if matches!(ext.as_str(), "jpg" | "jpeg") {
811 found.push(path);
812 }
813 }
814 }
815 assert_eq!(found.len(), 2);
816 Ok(())
817 }
818
819 #[test]
820 fn image_opti_plugin_nested_directories() -> Result<()> {
821 let temp = tempdir().unwrap();
823 let subdir = temp.path().join("subdir");
824 fs::create_dir(&subdir).unwrap();
825 fs::write(subdir.join("deep.png"), "PNG").unwrap();
826 fs::write(temp.path().join("top.png"), "PNG").unwrap();
827
828 let ctx = test_ctx_with(temp.path());
830 ImageOptiPlugin.after_compile(&ctx).unwrap();
831
832 Ok(())
835 }
836
837 #[test]
838 fn deploy_plugin_custom_target() -> Result<()> {
839 let temp = tempdir().unwrap();
841 let ctx = test_ctx_with(temp.path());
842 let target_name = "staging-eu-west-1";
843 let plugin = DeployPlugin::new(target_name);
844
845 plugin.after_compile(&ctx).unwrap();
847
848 assert_eq!(plugin.target, target_name);
850 Ok(())
851 }
852
853 #[test]
854 fn minify_plugin_nonexistent_dir_returns_ok() -> Result<()> {
855 let ctx = test_ctx_with(Path::new("/this/path/does/not/exist/at/all"));
857
858 assert!(MinifyPlugin.after_compile(&ctx).is_ok());
860 Ok(())
861 }
862
863 #[test]
868 fn minify_html_empty_string() {
869 let result = minify_html("");
870 assert_eq!(result, "");
871 }
872
873 #[test]
874 fn minify_html_whitespace_only() {
875 let result = minify_html(" \n\t \n ");
876 assert_eq!(result, " ");
877 }
878
879 #[test]
880 fn minify_html_no_whitespace() {
881 let input = "<p>hello</p>";
882 let result = minify_html(input);
883 assert_eq!(result, input);
884 }
885
886 #[test]
887 fn minify_html_preserves_pre_with_class() {
888 let input = "<pre class=\"lang-rust\"> fn main() { } </pre>";
889 let result = minify_html(input);
890 assert_eq!(result, input);
891 }
892
893 #[test]
894 fn minify_html_tabs_and_newlines() {
895 let input = "<div>\n\t<p>\n\t\tHello\n\t</p>\n</div>";
896 let result = minify_html(input);
897 assert_eq!(result, "<div> <p> Hello </p> </div>");
898 }
899
900 #[test]
901 fn minify_html_mixed_whitespace_types() {
902 let input = "<span> \t\n word \t\n </span>";
903 let result = minify_html(input);
904 assert_eq!(result, "<span> word </span>");
905 }
906
907 #[test]
908 fn minify_html_single_char() {
909 assert_eq!(minify_html("a"), "a");
910 assert_eq!(minify_html(" "), " ");
911 }
912
913 #[test]
914 fn minify_html_multiple_pre_tags() {
915 let input = "<pre>a</pre><pre>b</pre>";
916 let result = minify_html(input);
917 assert_eq!(result, input);
918 }
919
920 #[test]
925 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
926 fn minify_plugin_processes_multiple_html_files() -> Result<()> {
927 let temp = tempdir().unwrap();
928 fs::write(temp.path().join("a.html"), "<p> hello </p>").unwrap();
929 fs::write(temp.path().join("b.html"), "<div> world </div>").unwrap();
930 fs::write(temp.path().join("c.txt"), " not html ").unwrap();
931
932 let ctx = test_ctx_with(temp.path());
933 MinifyPlugin.after_compile(&ctx).unwrap();
934
935 let a = fs::read_to_string(temp.path().join("a.html")).unwrap();
936 let b = fs::read_to_string(temp.path().join("b.html")).unwrap();
937 let c = fs::read_to_string(temp.path().join("c.txt")).unwrap();
938
939 assert!(!a.contains(" "), "a.html should be minified");
940 assert!(!b.contains(" "), "b.html should be minified");
941 assert!(c.contains(" "), "c.txt should not be minified");
942 Ok(())
943 }
944
945 #[test]
946 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
947 fn minify_plugin_whitespace_only_html_file() -> Result<()> {
948 let temp = tempdir().unwrap();
949 fs::write(temp.path().join("ws.html"), " \n\t \n ").unwrap();
950
951 let ctx = test_ctx_with(temp.path());
952 MinifyPlugin.after_compile(&ctx).unwrap();
953
954 let content = fs::read_to_string(temp.path().join("ws.html")).unwrap();
955 assert_eq!(content, " ");
956 Ok(())
957 }
958
959 #[test]
960 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
961 fn minify_plugin_keeps_pre_and_minifies_the_rest() -> Result<()> {
962 let temp = tempdir().unwrap();
963 let original =
964 "<html><pre> keep spaces </pre><p> other </p></html>";
965 fs::write(temp.path().join("pre.html"), original).unwrap();
966
967 let ctx = test_ctx_with(temp.path());
968 MinifyPlugin.after_compile(&ctx).unwrap();
969
970 let content = fs::read_to_string(temp.path().join("pre.html")).unwrap();
975 assert_eq!(
976 content,
977 "<html><pre> keep spaces </pre><p> other </p></html>"
978 );
979 Ok(())
980 }
981
982 #[test]
987 fn image_opti_plugin_finds_gif_and_bmp() -> Result<()> {
988 let temp = tempdir().unwrap();
989 fs::write(temp.path().join("anim.gif"), "GIF").unwrap();
990 fs::write(temp.path().join("icon.bmp"), "BMP").unwrap();
991 fs::write(temp.path().join("doc.pdf"), "PDF").unwrap();
992 fs::write(temp.path().join("Makefile"), "all:").unwrap();
995
996 let ctx = test_ctx_with(temp.path());
997 ImageOptiPlugin.after_compile(&ctx).unwrap();
998
999 let mut count = 0;
1003 for entry in fs::read_dir(temp.path()).unwrap() {
1004 let path = entry.unwrap().path();
1005 if let Some(ext) = path.extension() {
1006 let ext = ext.to_string_lossy().to_lowercase();
1007 if matches!(ext.as_str(), "gif" | "bmp") {
1008 count += 1;
1009 }
1010 }
1011 }
1012 assert_eq!(count, 2);
1013 Ok(())
1014 }
1015
1016 #[test]
1017 fn image_opti_plugin_empty_dir_no_crash() -> Result<()> {
1018 let temp = tempdir().unwrap();
1019 let ctx = test_ctx_with(temp.path());
1020 ImageOptiPlugin.after_compile(&ctx).unwrap();
1021 Ok(())
1022 }
1023
1024 #[test]
1025 fn image_opti_plugin_no_images() -> Result<()> {
1026 let temp = tempdir().unwrap();
1027 fs::write(temp.path().join("readme.txt"), "text").unwrap();
1028 fs::write(temp.path().join("style.css"), "css").unwrap();
1029
1030 let ctx = test_ctx_with(temp.path());
1031 ImageOptiPlugin.after_compile(&ctx).unwrap();
1032 Ok(())
1033 }
1034
1035 #[test]
1036 fn image_opti_plugin_files_without_extension() -> Result<()> {
1037 let temp = tempdir().unwrap();
1038 fs::write(temp.path().join("Makefile"), "all:").unwrap();
1039 fs::write(temp.path().join("LICENSE"), "MIT").unwrap();
1040
1041 let ctx = test_ctx_with(temp.path());
1042 ImageOptiPlugin.after_compile(&ctx).unwrap();
1043 Ok(())
1044 }
1045
1046 #[test]
1051 fn deploy_plugin_empty_target() -> Result<()> {
1052 let temp = tempdir().unwrap();
1053 let ctx = test_ctx_with(temp.path());
1054 let plugin = DeployPlugin::new("");
1055 plugin.after_compile(&ctx).unwrap();
1056 assert_eq!(plugin.target, "");
1057 Ok(())
1058 }
1059
1060 #[test]
1061 fn deploy_plugin_various_targets() -> Result<()> {
1062 let temp = tempdir().unwrap();
1063 let ctx = test_ctx_with(temp.path());
1064
1065 for target in ["staging", "production", "preview", "canary"] {
1066 let plugin = DeployPlugin::new(target);
1067 assert_eq!(plugin.name(), "deploy");
1068 assert_eq!(plugin.target, target);
1069 plugin.after_compile(&ctx).unwrap();
1070 }
1071 Ok(())
1072 }
1073
1074 #[test]
1075 fn deploy_plugin_debug_format() {
1076 let plugin = DeployPlugin::new("prod");
1077 let debug = format!("{plugin:?}");
1078 assert!(debug.contains("prod"));
1079 }
1080
1081 #[test]
1086 fn minify_plugin_copy_clone() {
1087 let a = MinifyPlugin;
1088 let b = a;
1089 #[allow(clippy::clone_on_copy)]
1092 let c = a.clone();
1093 assert_eq!(a.name(), b.name());
1094 assert_eq!(a.name(), c.name());
1095 }
1096
1097 #[test]
1098 fn minify_plugin_debug_format() {
1099 let debug = format!("{:?}", MinifyPlugin);
1100 assert!(debug.contains("MinifyPlugin"));
1101 }
1102
1103 #[test]
1104 fn image_opti_plugin_copy_clone() {
1105 let a = ImageOptiPlugin;
1106 let b = a;
1107 #[allow(clippy::clone_on_copy)]
1110 let c = a.clone();
1111 assert_eq!(a.name(), b.name());
1112 assert_eq!(a.name(), c.name());
1113 }
1114
1115 #[test]
1116 fn image_opti_plugin_debug_format() {
1117 let debug = format!("{:?}", ImageOptiPlugin);
1118 assert!(debug.contains("ImageOptiPlugin"));
1119 }
1120
1121 #[test]
1122 fn test_minify_plugin_read_dir_error() {
1123 let temp = tempdir().unwrap();
1124 let file_path = temp.path().join("not_a_dir");
1125 fs::write(&file_path, "").unwrap();
1126 let ctx = test_ctx_with(&file_path);
1127 let res = MinifyPlugin.after_compile(&ctx);
1128 assert!(res.is_err());
1129 }
1130
1131 #[test]
1132 fn test_image_opti_plugin_read_dir_error() {
1133 let temp = tempdir().unwrap();
1134 let file_path = temp.path().join("not_a_dir");
1135 fs::write(&file_path, "").unwrap();
1136 let ctx = test_ctx_with(&file_path);
1137 let res = ImageOptiPlugin.after_compile(&ctx);
1138 assert!(res.is_err());
1139 }
1140
1141 #[test]
1146 fn minify_html_preserves_pre_content_bit_identical() {
1147 let body = "fn main() {\n println!(\"hi\");\n}";
1148 let input =
1149 format!("<html><body><pre><code>{body}</code></pre></body></html>");
1150 let out = minify_html(&input);
1151 assert!(
1155 out.contains(body),
1156 "minified output must preserve <pre> body byte-for-byte:\n{out}"
1157 );
1158 }
1159
1160 #[test]
1161 fn minify_css_compresses_input() {
1162 let input =
1163 "body {\n color: red;\n margin: 0px 0px 0px 0px;\n}";
1164 let out = minify_css(input);
1165 assert!(out.len() < input.len());
1166 assert!(out.contains("red"));
1167 }
1168
1169 #[test]
1170 fn minify_js_compresses_input() {
1171 let input = "const greeting = 'hello world';\nconsole.log(greeting);";
1172 let out = minify_js(input);
1173 assert!(out.len() < input.len());
1174 }
1175
1176 #[test]
1177 fn minify_plugin_recursive_walk_processes_nested_html() -> Result<()> {
1178 let temp = tempdir().unwrap();
1179 let deep = temp.path().join("blog").join("2026").join("post");
1180 fs::create_dir_all(&deep).unwrap();
1181 let nested = deep.join("index.html");
1182 fs::write(
1183 &nested,
1184 "<html> <body> <p> nested </p> </body> </html>",
1185 )
1186 .unwrap();
1187 let top = temp.path().join("index.html");
1188 fs::write(&top, "<html> <body> <p> top </p> </body></html>")
1189 .unwrap();
1190
1191 let ctx = test_ctx_with(temp.path());
1192 MinifyPlugin.after_compile(&ctx).unwrap();
1193
1194 let nested_after = fs::read_to_string(&nested).unwrap();
1195 assert!(
1197 nested_after.len()
1198 < "<html> <body> <p> nested </p> </body> </html>"
1199 .len(),
1200 "nested file should have been minified: {nested_after}"
1201 );
1202 Ok(())
1203 }
1204
1205 #[test]
1210 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
1211 fn minify_plugin_read_failure_on_invalid_utf8_html() {
1212 let temp = tempdir().unwrap();
1213 fs::write(temp.path().join("broken.html"), [0xFF, 0xFE, 0xFD]).unwrap();
1215
1216 let ctx = test_ctx_with(temp.path());
1217 let err = MinifyPlugin
1218 .after_compile(&ctx)
1219 .expect_err("invalid UTF-8 html must surface a read error");
1220 let msg = format!("{err:?}");
1221 assert!(msg.contains("broken.html"), "path context expected: {msg}");
1222 }
1223
1224 #[test]
1225 #[cfg(unix)]
1226 #[cfg_attr(feature = "test-fault-injection", serial_test::serial)]
1227 fn minify_plugin_write_failure_on_readonly_html() {
1228 use std::os::unix::fs::PermissionsExt;
1229
1230 let temp = tempdir().unwrap();
1231 let file = temp.path().join("locked.html");
1232 fs::write(&file, "<p> spaced out </p>").unwrap();
1233 fs::set_permissions(&file, fs::Permissions::from_mode(0o444)).unwrap();
1234
1235 let ctx = test_ctx_with(temp.path());
1236 let result = MinifyPlugin.after_compile(&ctx);
1237 fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
1238 let err =
1239 result.expect_err("write to a read-only html file must surface");
1240 let msg = format!("{err:?}");
1241 assert!(msg.contains("locked.html"), "path context expected: {msg}");
1242 }
1243}
1244
1245#[cfg(all(test, feature = "test-fault-injection"))]
1246mod fault_tests {
1247 use super::*;
1248 use crate::plugin::PluginContext;
1249 use serial_test::serial;
1250 use tempfile::tempdir;
1251
1252 struct FailGuard(&'static str);
1255
1256 impl Drop for FailGuard {
1257 fn drop(&mut self) {
1258 let _ = fail::cfg(self.0, "off");
1259 }
1260 }
1261
1262 #[test]
1263 #[serial]
1264 fn minify_read_failpoint_propagates() {
1265 let _guard = FailGuard("plugins::minify-read");
1266 fail::cfg("plugins::minify-read", "return")
1267 .expect("activate failpoint");
1268
1269 let dir = tempdir().unwrap();
1270 fs::write(dir.path().join("index.html"), "<p>x</p>").unwrap();
1271
1272 let ctx =
1273 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
1274 let err = MinifyPlugin
1275 .after_compile(&ctx)
1276 .expect_err("injected read failure must propagate");
1277 assert!(format!("{err:?}").contains("injected: plugins::minify-read"));
1278 }
1279
1280 #[test]
1281 #[serial]
1282 fn minify_write_failpoint_propagates() {
1283 let _guard = FailGuard("plugins::minify-write");
1284 fail::cfg("plugins::minify-write", "return")
1285 .expect("activate failpoint");
1286
1287 let dir = tempdir().unwrap();
1288 fs::write(dir.path().join("index.html"), "<p>x</p>").unwrap();
1289
1290 let ctx =
1291 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
1292 let err = MinifyPlugin
1293 .after_compile(&ctx)
1294 .expect_err("injected write failure must propagate");
1295 assert!(format!("{err:?}").contains("injected: plugins::minify-write"));
1296 }
1297}