Skip to main content

ssg/plugins/
plugins.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Built-in plugins
5//!
6//! Ready-to-use plugins for common static site generation tasks.
7//!
8//! - `MinifyPlugin` — Minifies the HTML, CSS and JavaScript files in the
9//!   site output directory, walking it recursively.
10//! - `ImageOptiPlugin` — Logs image files for optimization (stub for external tooling).
11//! - `DeployPlugin` — Logs deployment target after build (stub for CI integration).
12
13use 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/// Minifies the HTML, CSS and JavaScript files in the site output.
21///
22/// Runs during the `after_compile` hook. It walks `site_dir`
23/// recursively without following symlinks, skips files the plugin cache
24/// reports unchanged, and rewrites `.html` files with [`minify_html`],
25/// `.css` files with [`minify_css`] and `.js` files with [`minify_js`],
26/// ssg's own minifiers.
27///
28/// # Example
29///
30/// ```rust
31/// use ssg::plugin::PluginManager;
32/// use ssg::plugins::MinifyPlugin;
33///
34/// let mut pm = PluginManager::new();
35/// pm.register(MinifyPlugin);
36/// ```
37#[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 and JS go through ssg's own minifiers, the same ones the
83        // asset pipeline uses. They used to run only under a `minify`
84        // feature that pulled in minify-html, lightningcss and five oxc
85        // crates; the default build populated these lists and then threw
86        // them away.
87        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
115/// `(html, css, js)` file lists returned by [`collect_minifiable_files`].
116type MinifiableFiles = (Vec<PathBuf>, Vec<PathBuf>, Vec<PathBuf>);
117
118/// Walks `site_dir` and returns `(html, css, js)` file lists, honouring
119/// the plugin cache for incremental builds.
120///
121/// The walk is iterative rather than recursive so a deep tree cannot
122/// overflow the stack, and symlinks are not followed - the same contract
123/// `walkdir`'s `follow_links(false)` gave, without the dependency. Errors on
124/// individual entries are skipped; only failing to read `site_dir` itself is
125/// reported, which is what the previous implementation did.
126fn 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    // Probe the root first so an unreadable site directory is an error
135    // rather than an empty result; deeper directories are skipped quietly,
136    // which is what filtering `walkdir`'s errors did.
137    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            // Cache check applies uniformly to all minifiable assets.
163            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
177/// Elements whose text content must survive byte for byte.
178///
179/// `pre` and `textarea` render whitespace literally; `script` and `style`
180/// hold source in another language, where a run of spaces can sit inside a
181/// string literal. Collapsing any of them changes what the page does.
182const RAW_TEXT_ELEMENTS: [&str; 4] = ["pre", "textarea", "script", "style"];
183
184/// Scans one tag starting at `start` (which must index a `<`).
185///
186/// Returns the byte index just past the closing `>` and the lowercased
187/// element name for an opening tag. Attribute values are scanned with quote
188/// tracking, so a `>` inside one does not end the tag early.
189fn 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
229/// Byte offset of `</name` in `hay`, case-insensitively, without allocating.
230fn 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/// Collapses insignificant whitespace in HTML.
246///
247/// This is ssg's own implementation rather than a dependency. Minification
248/// rewrites every page the generator emits, so a bug in it is a bug in every
249/// site; keeping it in-tree means it is covered by this crate's own tests and
250/// cannot change underneath a release.
251///
252/// What it does not touch:
253///
254/// * the content of `<pre>`, `<textarea>`, `<script>` and `<style>`,
255///   byte for byte
256/// * anything between `<` and `>`, so attribute values keep their spacing
257/// * comments, including conditional ones
258///
259/// A previous version bailed out of the whole document if `<pre` appeared
260/// anywhere, and collapsed whitespace everywhere else - including inside
261/// `<script>`, where it silently rewrote string literals. This one tracks
262/// which element it is inside, so a page can contain a `<pre>` block and
263/// still be minified around it.
264///
265/// # Examples
266///
267/// ```rust
268/// use ssg::plugins::minify_html;
269///
270/// assert_eq!(minify_html("<p>  Hello   World  </p>"), "<p> Hello World </p>");
271///
272/// // A script's contents are left exactly as written.
273/// let js = r#"<script>var s = "a  b";</script>"#;
274/// assert_eq!(minify_html(js), js);
275/// ```
276#[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    // Whitespace seen in a text run, not yet emitted. Holding it back means a
282    // run collapses to one space and the space lands before the next thing,
283    // whether that is text or a tag.
284    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
334/// ssg's own CSS and JavaScript minifiers, re-exported so the whole
335/// minification surface lives behind one module.
336///
337/// These replace `lightningcss` and `oxc_minifier`, which sat behind an
338/// optional `minify` feature. A minifier rewrites every byte the generator
339/// emits; keeping it in-tree means it is covered by this crate's own tests
340/// and cannot change underneath a release.
341pub use crate::plugins_group::assets::{minify_css, minify_js};
342
343/// Image optimization plugin stub.
344///
345/// Scans the site directory for image files and logs them.
346/// Actual optimization requires external tools (e.g., `cwebp`, `avifenc`).
347///
348/// # Example
349///
350/// ```rust
351/// use ssg::plugin::PluginManager;
352/// use ssg::plugins::ImageOptiPlugin;
353///
354/// let mut pm = PluginManager::new();
355/// pm.register(ImageOptiPlugin);
356/// ```
357#[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/// Deployment plugin stub.
394///
395/// Logs the deployment target after a successful build.
396/// Extend with actual deployment logic for Vercel, Netlify, or Cloudflare.
397///
398/// # Example
399///
400/// ```rust
401/// use ssg::plugin::PluginManager;
402/// use ssg::plugins::DeployPlugin;
403///
404/// let mut pm = PluginManager::new();
405/// pm.register(DeployPlugin::new("production"));
406/// ```
407/// Superseded by [`crate::deploy::DeployPlugin`], which is the implementation
408/// the pipeline registers. This one was never wired into a build; it survived
409/// as a second, divergent copy of the same idea.
410#[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    /// Creates a new deployment plugin for the given target environment.
422    ///
423    /// # Examples
424    ///
425    /// ```rust
426    /// use ssg::plugins::DeployPlugin;
427    /// use ssg::plugin::Plugin;
428    ///
429    /// let p = DeployPlugin::new("production");
430    /// assert_eq!(p.name(), "deploy");
431    /// ```
432    #[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    // These tests exercise the deprecated plugin deliberately: it is
459    // still shipped for one release, and this is what keeps it working
460    // until removal.
461    #![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        // `collect_minifiable_files`'s
511        // `cache.is_none_or(|c| c.has_changed(p))` closure is only
512        // ever invoked when `ctx.cache` is `Some(..)` — every other
513        // test in this file leaves it `None`, where `is_none_or`
514        // short-circuits without calling the closure at all.
515        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        // Unchanged per the cache ⇒ filtered out ⇒ left untouched.
527        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        // The same closure's "changed" arm: a cache entry recorded
536        // against different content means `has_changed` returns
537        // `true`, so the file is still processed.
538        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        // This used to assert the opposite - that the file came back with
566        // its three spaces intact - because CSS was only minified when the
567        // `minify` feature pulled in lightningcss. The default build
568        // collected the file and threw the list away.
569        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        // Every one of these was corrupted by the previous implementation,
589        // which collapsed whitespace everywhere outside a `<pre>`-bearing
590        // document. A run of spaces inside a string literal is data.
591        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        // The old pass gave up on the whole document the moment `<pre`
608        // appeared anywhere in it, so a single code block cost every other
609        // byte on the page.
610        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        // Arrange
718        let input = "<pre>  code   with   spaces  </pre><p>  other  </p>";
719
720        // Act
721        let result = minify_html(input);
722
723        // Assert — the <pre> survives byte for byte, the rest is collapsed
724        assert_eq!(result, "<pre>  code   with   spaces  </pre><p> other </p>");
725    }
726
727    #[test]
728    fn minify_plugin_handles_nested_html() {
729        // Arrange
730        let input = "<div>  <section>  <article>  <p>  deep  </p>  </article>  </section>  </div>";
731
732        // Act
733        let result = minify_html(input);
734
735        // Assert — runs of whitespace collapsed to single spaces
736        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        // Arrange
746        let temp = tempdir().unwrap();
747        let html_path = temp.path().join("empty.html");
748        fs::write(&html_path, "").unwrap();
749
750        // Act
751        let ctx = test_ctx_with(temp.path());
752        MinifyPlugin.after_compile(&ctx).unwrap();
753
754        // Assert — file exists, no crash
755        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        // Arrange
763        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        // Extensionless entry drives the `path.extension()` None branch
768        // in the verification loop below.
769        fs::write(temp.path().join("LICENSE"), "MIT").unwrap();
770
771        // Act
772        let ctx = test_ctx_with(temp.path());
773        ImageOptiPlugin.after_compile(&ctx).unwrap();
774
775        // Assert — plugin runs without error (it only logs; we verify no crash)
776        // Also verify both extensions are recognized by the match arm
777        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        // Arrange — ImageOptiPlugin only reads top-level (read_dir, not recursive)
794        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        // Act
801        let ctx = test_ctx_with(temp.path());
802        ImageOptiPlugin.after_compile(&ctx).unwrap();
803
804        // Assert — plugin completes without error; subdir images are not
805        // discovered since read_dir is non-recursive
806        Ok(())
807    }
808
809    #[test]
810    fn deploy_plugin_custom_target() -> Result<()> {
811        // Arrange
812        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        // Act — after_compile prints the target
818        plugin.after_compile(&ctx).unwrap();
819
820        // Assert — the stored target matches what was provided
821        assert_eq!(plugin.target, target_name);
822        Ok(())
823    }
824
825    #[test]
826    fn minify_plugin_nonexistent_dir_returns_ok() -> Result<()> {
827        // Arrange
828        let ctx = test_ctx_with(Path::new("/this/path/does/not/exist/at/all"));
829
830        // Act & Assert — returns Ok without error
831        assert!(MinifyPlugin.after_compile(&ctx).is_ok());
832        Ok(())
833    }
834
835    // -----------------------------------------------------------------
836    // minify_html — additional edge cases (fallback only)
837    // -----------------------------------------------------------------
838
839    #[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    // -----------------------------------------------------------------
893    // MinifyPlugin — multiple HTML files
894    // -----------------------------------------------------------------
895
896    #[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        // The `<pre>` keeps every byte; the paragraph beside it does not.
943        // This used to assert the whole document came back untouched,
944        // because one `<pre>` anywhere disabled minification for the entire
945        // page - a code block cost every other byte on it.
946        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    // -----------------------------------------------------------------
955    // ImageOptiPlugin — additional file types
956    // -----------------------------------------------------------------
957
958    #[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        // Extensionless entry drives the `path.extension()` None branch
965        // in the verification loop below.
966        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        // Verify the plugin ran without error. The plugin only logs —
972        // we verify it recognizes gif/bmp by not crashing and check
973        // file counts manually.
974        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    // -----------------------------------------------------------------
1019    // DeployPlugin — additional targets
1020    // -----------------------------------------------------------------
1021
1022    #[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    // -----------------------------------------------------------------
1054    // MinifyPlugin / ImageOptiPlugin — trait object coverage
1055    // -----------------------------------------------------------------
1056
1057    #[test]
1058    fn minify_plugin_copy_clone() {
1059        let a = MinifyPlugin;
1060        let b = a;
1061        // Cloning a Copy type is the point: this asserts `Clone` is wired up,
1062        // not that cloning is the efficient way to get a second value.
1063        #[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        // Cloning a Copy type is the point: this asserts `Clone` is wired up,
1080        // not that cloning is the efficient way to get a second value.
1081        #[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    // -----------------------------------------------------------------
1114    // `minify_html` — happy paths
1115    // -----------------------------------------------------------------
1116
1117    #[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        // The exact whitespace inside <pre><code>…</code></pre> must
1124        // survive minification untouched. We only check containment
1125        // because minify-html may rewrite attributes outside the pre.
1126        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        // Nested file must have been touched (size strictly smaller).
1168        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    // -----------------------------------------------------------------
1178    // MinifyPlugin — per-file read/write error propagation
1179    // -----------------------------------------------------------------
1180
1181    #[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        // Invalid UTF-8 makes read_to_string fail inside the html pass.
1186        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    /// RAII guard that disables a failpoint on drop (mirrors the
1225    /// convention in `tests/fault_injection.rs`).
1226    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}