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 HTML files in the site output directory.
9//!   With the `minify` feature enabled, also minifies `.css` and `.js`
10//!   assets and walks the site directory recursively.
11//! - `ImageOptiPlugin` — Logs image files for optimization (stub for external tooling).
12//! - `DeployPlugin` — Logs deployment target after build (stub for CI integration).
13
14use 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/// Minifies HTML files and (with the `minify` feature) JS/CSS assets.
22///
23/// Runs during the `after_compile` hook.
24///
25/// * **Default build:** processes only top-level `.html` files in
26///   `site_dir`, falling back to a whitespace-collapsing pass that
27///   short-circuits on any document containing `<pre`.
28/// * **`minify` feature:** walks `site_dir` recursively (via `walkdir`)
29///   and uses
30///   [`minify-html`](https://crates.io/crates/minify-html) for HTML,
31///   [`oxc_minifier`](https://crates.io/crates/oxc_minifier) for JS, and
32///   [`lightningcss`](https://crates.io/crates/lightningcss) for CSS.
33///   `<pre>` content is preserved bit-identically by `minify-html`'s
34///   native handling.
35///
36/// # Example
37///
38/// ```rust
39/// use ssg::plugin::PluginManager;
40/// use ssg::plugins::MinifyPlugin;
41///
42/// let mut pm = PluginManager::new();
43/// pm.register(MinifyPlugin);
44/// ```
45#[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 and JS go through ssg's own minifiers, the same ones the
91        // asset pipeline uses. They used to run only under a `minify`
92        // feature that pulled in minify-html, lightningcss and five oxc
93        // crates; the default build populated these lists and then threw
94        // them away.
95        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
123/// `(html, css, js)` file lists returned by [`collect_minifiable_files`].
124type MinifiableFiles = (Vec<PathBuf>, Vec<PathBuf>, Vec<PathBuf>);
125
126/// Walks `site_dir` and returns `(html, css, js)` file lists, honouring
127/// the plugin cache for incremental builds.
128///
129/// The walk is iterative rather than recursive so a deep tree cannot
130/// overflow the stack, and symlinks are not followed - the same contract
131/// `walkdir`'s `follow_links(false)` gave, without the dependency. Errors on
132/// individual entries are skipped; only failing to read `site_dir` itself is
133/// reported, which is what the previous implementation did.
134fn 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    // Probe the root first so an unreadable site directory is an error
143    // rather than an empty result; deeper directories are skipped quietly,
144    // which is what filtering `walkdir`'s errors did.
145    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            // Cache check applies uniformly to all minifiable assets.
171            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
185/// HTML minification.
186///
187/// * With the `minify` feature: delegates to `minify-html` configured
188///   with `keep_comments: false`, `do_not_minify_doctype: true`. CSS
189///   inside `<style>` and JS inside `<script>` are passed through
190///   without inline minification (the dedicated asset-file passes
191///   handle that, and avoid double-minification of inline blocks that
192///   may contain template-specific syntax).
193/// * Without the feature: falls back to a whitespace-collapsing pass
194///   that short-circuits when any `<pre` substring is present so
195///   user-visible whitespace in code blocks is preserved.
196///
197/// # Examples
198///
199/// ```rust
200/// use ssg::plugins::minify_html;
201///
202/// let out = minify_html("<html>   <body>hi</body>  </html>");
203/// assert!(out.len() <= "<html>   <body>hi</body>  </html>".len());
204/// ```
205/// Elements whose text content must survive byte for byte.
206///
207/// `pre` and `textarea` render whitespace literally; `script` and `style`
208/// hold source in another language, where a run of spaces can sit inside a
209/// string literal. Collapsing any of them changes what the page does.
210const RAW_TEXT_ELEMENTS: [&str; 4] = ["pre", "textarea", "script", "style"];
211
212/// Scans one tag starting at `start` (which must index a `<`).
213///
214/// Returns the byte index just past the closing `>` and the lowercased
215/// element name for an opening tag. Attribute values are scanned with quote
216/// tracking, so a `>` inside one does not end the tag early.
217fn 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
257/// Byte offset of `</name` in `hay`, case-insensitively, without allocating.
258fn 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/// Collapses insignificant whitespace in HTML.
274///
275/// This is ssg's own implementation rather than a dependency. Minification
276/// rewrites every page the generator emits, so a bug in it is a bug in every
277/// site; keeping it in-tree means it is covered by this crate's own tests and
278/// cannot change underneath a release.
279///
280/// What it does not touch:
281///
282/// * the content of `<pre>`, `<textarea>`, `<script>` and `<style>`,
283///   byte for byte
284/// * anything between `<` and `>`, so attribute values keep their spacing
285/// * comments, including conditional ones
286///
287/// A previous version bailed out of the whole document if `<pre` appeared
288/// anywhere, and collapsed whitespace everywhere else - including inside
289/// `<script>`, where it silently rewrote string literals. This one tracks
290/// which element it is inside, so a page can contain a `<pre>` block and
291/// still be minified around it.
292///
293/// # Examples
294///
295/// ```rust
296/// use ssg::plugins::minify_html;
297///
298/// assert_eq!(minify_html("<p>  Hello   World  </p>"), "<p> Hello World </p>");
299///
300/// // A script's contents are left exactly as written.
301/// let js = r#"<script>var s = "a  b";</script>"#;
302/// assert_eq!(minify_html(js), js);
303/// ```
304#[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    // Whitespace seen in a text run, not yet emitted. Holding it back means a
310    // run collapses to one space and the space lands before the next thing,
311    // whether that is text or a tag.
312    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
362/// ssg's own CSS and JavaScript minifiers, re-exported so the whole
363/// minification surface lives behind one module.
364///
365/// These replace `lightningcss` and `oxc_minifier`, which sat behind an
366/// optional `minify` feature. A minifier rewrites every byte the generator
367/// emits; keeping it in-tree means it is covered by this crate's own tests
368/// and cannot change underneath a release.
369pub use crate::plugins_group::assets::{minify_css, minify_js};
370
371/// Image optimization plugin stub.
372///
373/// Scans the site directory for image files and logs them.
374/// Actual optimization requires external tools (e.g., `cwebp`, `avifenc`).
375///
376/// # Example
377///
378/// ```rust
379/// use ssg::plugin::PluginManager;
380/// use ssg::plugins::ImageOptiPlugin;
381///
382/// let mut pm = PluginManager::new();
383/// pm.register(ImageOptiPlugin);
384/// ```
385#[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/// Deployment plugin stub.
422///
423/// Logs the deployment target after a successful build.
424/// Extend with actual deployment logic for Vercel, Netlify, or Cloudflare.
425///
426/// # Example
427///
428/// ```rust
429/// use ssg::plugin::PluginManager;
430/// use ssg::plugins::DeployPlugin;
431///
432/// let mut pm = PluginManager::new();
433/// pm.register(DeployPlugin::new("production"));
434/// ```
435/// Superseded by [`crate::deploy::DeployPlugin`], which is the implementation
436/// the pipeline registers. This one was never wired into a build; it survived
437/// as a second, divergent copy of the same idea.
438#[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    /// Creates a new deployment plugin for the given target environment.
450    ///
451    /// # Examples
452    ///
453    /// ```rust
454    /// use ssg::plugins::DeployPlugin;
455    /// use ssg::plugin::Plugin;
456    ///
457    /// let p = DeployPlugin::new("production");
458    /// assert_eq!(p.name(), "deploy");
459    /// ```
460    #[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    // These tests exercise the deprecated plugin deliberately: it is
487    // still shipped for one release, and this is what keeps it working
488    // until removal.
489    #![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        // `collect_minifiable_files`'s
539        // `cache.is_none_or(|c| c.has_changed(p))` closure is only
540        // ever invoked when `ctx.cache` is `Some(..)` — every other
541        // test in this file leaves it `None`, where `is_none_or`
542        // short-circuits without calling the closure at all.
543        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        // Unchanged per the cache ⇒ filtered out ⇒ left untouched.
555        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        // The same closure's "changed" arm: a cache entry recorded
564        // against different content means `has_changed` returns
565        // `true`, so the file is still processed.
566        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        // This used to assert the opposite - that the file came back with
594        // its three spaces intact - because CSS was only minified when the
595        // `minify` feature pulled in lightningcss. The default build
596        // collected the file and threw the list away.
597        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        // Every one of these was corrupted by the previous implementation,
617        // which collapsed whitespace everywhere outside a `<pre>`-bearing
618        // document. A run of spaces inside a string literal is data.
619        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        // The old pass gave up on the whole document the moment `<pre`
636        // appeared anywhere in it, so a single code block cost every other
637        // byte on the page.
638        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        // Arrange
746        let input = "<pre>  code   with   spaces  </pre><p>  other  </p>";
747
748        // Act
749        let result = minify_html(input);
750
751        // Assert — the <pre> survives byte for byte, the rest is collapsed
752        assert_eq!(result, "<pre>  code   with   spaces  </pre><p> other </p>");
753    }
754
755    #[test]
756    fn minify_plugin_handles_nested_html() {
757        // Arrange
758        let input = "<div>  <section>  <article>  <p>  deep  </p>  </article>  </section>  </div>";
759
760        // Act
761        let result = minify_html(input);
762
763        // Assert — runs of whitespace collapsed to single spaces
764        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        // Arrange
774        let temp = tempdir().unwrap();
775        let html_path = temp.path().join("empty.html");
776        fs::write(&html_path, "").unwrap();
777
778        // Act
779        let ctx = test_ctx_with(temp.path());
780        MinifyPlugin.after_compile(&ctx).unwrap();
781
782        // Assert — file exists, no crash
783        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        // Arrange
791        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        // Extensionless entry drives the `path.extension()` None branch
796        // in the verification loop below.
797        fs::write(temp.path().join("LICENSE"), "MIT").unwrap();
798
799        // Act
800        let ctx = test_ctx_with(temp.path());
801        ImageOptiPlugin.after_compile(&ctx).unwrap();
802
803        // Assert — plugin runs without error (it only logs; we verify no crash)
804        // Also verify both extensions are recognized by the match arm
805        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        // Arrange — ImageOptiPlugin only reads top-level (read_dir, not recursive)
822        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        // Act
829        let ctx = test_ctx_with(temp.path());
830        ImageOptiPlugin.after_compile(&ctx).unwrap();
831
832        // Assert — plugin completes without error; subdir images are not
833        // discovered since read_dir is non-recursive
834        Ok(())
835    }
836
837    #[test]
838    fn deploy_plugin_custom_target() -> Result<()> {
839        // Arrange
840        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        // Act — after_compile prints the target
846        plugin.after_compile(&ctx).unwrap();
847
848        // Assert — the stored target matches what was provided
849        assert_eq!(plugin.target, target_name);
850        Ok(())
851    }
852
853    #[test]
854    fn minify_plugin_nonexistent_dir_returns_ok() -> Result<()> {
855        // Arrange
856        let ctx = test_ctx_with(Path::new("/this/path/does/not/exist/at/all"));
857
858        // Act & Assert — returns Ok without error
859        assert!(MinifyPlugin.after_compile(&ctx).is_ok());
860        Ok(())
861    }
862
863    // -----------------------------------------------------------------
864    // minify_html — additional edge cases (fallback only)
865    // -----------------------------------------------------------------
866
867    #[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    // -----------------------------------------------------------------
921    // MinifyPlugin — multiple HTML files
922    // -----------------------------------------------------------------
923
924    #[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        // The `<pre>` keeps every byte; the paragraph beside it does not.
971        // This used to assert the whole document came back untouched,
972        // because one `<pre>` anywhere disabled minification for the entire
973        // page - a code block cost every other byte on it.
974        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    // -----------------------------------------------------------------
983    // ImageOptiPlugin — additional file types
984    // -----------------------------------------------------------------
985
986    #[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        // Extensionless entry drives the `path.extension()` None branch
993        // in the verification loop below.
994        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        // Verify the plugin ran without error. The plugin only logs —
1000        // we verify it recognizes gif/bmp by not crashing and check
1001        // file counts manually.
1002        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    // -----------------------------------------------------------------
1047    // DeployPlugin — additional targets
1048    // -----------------------------------------------------------------
1049
1050    #[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    // -----------------------------------------------------------------
1082    // MinifyPlugin / ImageOptiPlugin — trait object coverage
1083    // -----------------------------------------------------------------
1084
1085    #[test]
1086    fn minify_plugin_copy_clone() {
1087        let a = MinifyPlugin;
1088        let b = a;
1089        // Cloning a Copy type is the point: this asserts `Clone` is wired up,
1090        // not that cloning is the efficient way to get a second value.
1091        #[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        // Cloning a Copy type is the point: this asserts `Clone` is wired up,
1108        // not that cloning is the efficient way to get a second value.
1109        #[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    // -----------------------------------------------------------------
1142    // `minify` feature — happy paths (only compiled with the feature)
1143    // -----------------------------------------------------------------
1144
1145    #[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        // The exact whitespace inside <pre><code>…</code></pre> must
1152        // survive minification untouched. We only check containment
1153        // because minify-html may rewrite attributes outside the pre.
1154        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        // Nested file must have been touched (size strictly smaller).
1196        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    // -----------------------------------------------------------------
1206    // MinifyPlugin — per-file read/write error propagation
1207    // -----------------------------------------------------------------
1208
1209    #[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        // Invalid UTF-8 makes read_to_string fail inside the html pass.
1214        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    /// RAII guard that disables a failpoint on drop (mirrors the
1253    /// convention in `tests/fault_injection.rs`).
1254    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}