Skip to main content

ssg/plugins/
plugin.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Plugin architecture for SSG
5//!
6//! Provides a trait-based plugin system with lifecycle hooks for
7//! extending the static site generation pipeline.
8//!
9//! ## Lifecycle hooks
10//!
11//! Plugins can hook into three stages of site generation:
12//!
13//! 1. **`before_compile`** — Runs before compilation. Use for content
14//!    preprocessing, metadata injection, or source transformation.
15//! 2. **`after_compile`** — Runs after compilation. Use for HTML
16//!    post-processing, asset optimization, or sitemap generation.
17//! 3. **`on_serve`** — Runs before the dev server starts. Use for
18//!    injecting dev-mode scripts or live-reload support.
19//!
20//! ## Example
21//!
22//! ```rust
23//! use ssg::plugin::{Plugin, PluginContext};
24//! use anyhow::Result;
25//!
26//! #[derive(Debug)]
27//! struct MinifyPlugin;
28//!
29//! impl Plugin for MinifyPlugin {
30//!     fn name(&self) -> &str { "minify" }
31//!
32//!     fn after_compile(&self, ctx: &PluginContext) -> std::result::Result<(), ssg::error::SsgError> {
33//!         println!("Minifying files in {:?}", ctx.site_dir);
34//!         // Walk site_dir and minify HTML/CSS/JS files
35//!         Ok(())
36//!     }
37//! }
38//! ```
39
40use crate::cmd::SsgConfig;
41use crate::error::{PathErrorExt, SsgError};
42use std::{
43    collections::BTreeMap,
44    fmt, fs,
45    path::{Path, PathBuf},
46    sync::Arc,
47};
48
49// =====================================================================
50// Content-addressed plugin cache
51// =====================================================================
52
53const CACHE_FILENAME: &str = ".ssg-plugin-cache.json";
54
55/// Content-addressed cache that tracks file hashes so plugins can skip
56/// unchanged files across incremental builds.
57///
58/// Stores `path → content_hash` mappings and persists to
59/// `.ssg-plugin-cache.json` in the site directory.
60#[derive(Debug, Clone, Default)]
61pub struct PluginCache {
62    entries: BTreeMap<PathBuf, u64>,
63}
64
65impl PluginCache {
66    /// Creates an empty cache.
67    ///
68    /// # Examples
69    ///
70    /// ```rust
71    /// use ssg::plugin::PluginCache;
72    /// use std::path::Path;
73    ///
74    /// let c = PluginCache::new();
75    /// assert!(c.has_changed(Path::new("any.path")));
76    /// ```
77    #[must_use]
78    pub const fn new() -> Self {
79        Self {
80            entries: BTreeMap::new(),
81        }
82    }
83
84    /// Loads a cache from `site_dir/.ssg-plugin-cache.json`.
85    ///
86    /// Returns an empty cache if the file does not exist or cannot be
87    /// parsed.
88    ///
89    /// # Examples
90    ///
91    /// ```rust
92    /// use ssg::plugin::PluginCache;
93    /// use tempfile::tempdir;
94    ///
95    /// let dir = tempdir().unwrap();
96    /// // Missing cache file ⇒ empty cache, no error.
97    /// let _c = PluginCache::load(dir.path());
98    /// ```
99    #[must_use]
100    pub fn load(site_dir: &Path) -> Self {
101        let path = site_dir.join(CACHE_FILENAME);
102        if !path.exists() {
103            return Self::new();
104        }
105        let Ok(content) = fs::read_to_string(&path) else {
106            return Self::new();
107        };
108        let Ok(map) = serde_json::from_str::<BTreeMap<String, u64>>(&content)
109        else {
110            return Self::new();
111        };
112        Self {
113            entries: map
114                .into_iter()
115                .map(|(k, v)| (PathBuf::from(k), v))
116                .collect(),
117        }
118    }
119
120    /// Persists the cache to `site_dir/.ssg-plugin-cache.json`.
121    ///
122    /// # Examples
123    ///
124    /// ```rust
125    /// use ssg::plugin::PluginCache;
126    /// use tempfile::tempdir;
127    ///
128    /// let dir = tempdir().unwrap();
129    /// PluginCache::new().save(dir.path()).unwrap();
130    /// assert!(dir.path().join(".ssg-plugin-cache.json").exists());
131    /// ```
132    pub fn save(&self, site_dir: &Path) -> Result<(), SsgError> {
133        let path = site_dir.join(CACHE_FILENAME);
134        let serialisable: BTreeMap<String, u64> = self
135            .entries
136            .iter()
137            .map(|(k, v)| (k.to_string_lossy().into_owned(), *v))
138            .collect();
139        // Infallible: BTreeMap<String, u64> is always serialisable to
140        // JSON. We keep the `map_err` for clippy::expect_used (which is
141        // denied in lib); the closure body is dark but harmless.
142        let json =
143            serde_json::to_string_pretty(&serialisable).map_err(|e| {
144                SsgError::Io {
145                    path: path.clone(),
146                    source: std::io::Error::other(e),
147                }
148            })?;
149        fs::write(&path, json).with_path(&path)?;
150        Ok(())
151    }
152
153    /// Returns `true` if the file at `path` has changed since the last
154    /// time it was recorded, or if it has never been recorded.
155    ///
156    /// # Examples
157    ///
158    /// ```rust
159    /// use ssg::plugin::PluginCache;
160    /// use tempfile::tempdir;
161    /// use std::fs;
162    ///
163    /// let dir = tempdir().unwrap();
164    /// let f = dir.path().join("x.txt");
165    /// fs::write(&f, "a").unwrap();
166    /// let mut c = PluginCache::new();
167    /// // Never recorded ⇒ has_changed = true.
168    /// assert!(c.has_changed(&f));
169    /// c.update(&f);
170    /// assert!(!c.has_changed(&f));
171    /// ```
172    pub fn has_changed(&self, path: &Path) -> bool {
173        let Ok(content) = fs::read(path) else {
174            return true;
175        };
176        let current = Self::hash_bytes(&content);
177        match self.entries.get(path) {
178            Some(&cached) => cached != current,
179            None => true,
180        }
181    }
182
183    /// Records the current content hash for `path`.
184    ///
185    /// # Examples
186    ///
187    /// ```rust
188    /// use ssg::plugin::PluginCache;
189    /// use tempfile::tempdir;
190    /// use std::fs;
191    ///
192    /// let dir = tempdir().unwrap();
193    /// let f = dir.path().join("x.txt");
194    /// fs::write(&f, "hi").unwrap();
195    /// let mut c = PluginCache::new();
196    /// c.update(&f);
197    /// assert!(!c.has_changed(&f));
198    /// ```
199    pub fn update(&mut self, path: &Path) {
200        if let Ok(content) = fs::read(path) {
201            let hash = Self::hash_bytes(&content);
202            let _ = self.entries.insert(path.to_path_buf(), hash);
203        }
204    }
205
206    /// Simple FNV-1a 64-bit hash of a byte slice.
207    fn hash_bytes(data: &[u8]) -> u64 {
208        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
209        for &byte in data {
210            hash ^= u64::from(byte);
211            hash = hash.wrapping_mul(0x0100_0000_01b3);
212        }
213        hash
214    }
215}
216
217/// Context passed to plugin hooks with paths and configuration.
218#[derive(Debug, Clone)]
219pub struct PluginContext {
220    /// The content source directory.
221    pub content_dir: PathBuf,
222    /// The build/output directory.
223    pub build_dir: PathBuf,
224    /// The final site directory.
225    pub site_dir: PathBuf,
226    /// The template directory.
227    pub template_dir: PathBuf,
228    /// Site configuration (`base_url`, `site_name`, language, etc.).
229    pub config: Option<SsgConfig>,
230    /// Content-addressed plugin cache for incremental builds.
231    pub cache: Option<PluginCache>,
232    /// Memory budget for streaming compilation.
233    pub memory_budget: Option<crate::streaming::MemoryBudget>,
234    /// Cached list of HTML files in `site_dir`, walked once and shared
235    /// across all plugins to avoid redundant filesystem traversals.
236    pub html_files: Option<Arc<Vec<PathBuf>>>,
237    /// Page dependency graph for incremental rebuilds.
238    pub dep_graph: Option<crate::depgraph::DepGraph>,
239    /// When `true`, plugins should perform validation passes only and
240    /// must not write to disk. Set by the `ssg check` subcommand
241    /// (issue #527). Plugins that don't have a meaningful read-only
242    /// mode may safely ignore this flag.
243    pub dry_run: bool,
244}
245
246impl PluginContext {
247    /// Populates the cached HTML file list by walking `site_dir` once.
248    /// Call this before running `after_compile` plugins to eliminate
249    /// redundant directory scans (8+ plugins read the same file list).
250    ///
251    /// # Examples
252    ///
253    /// ```rust
254    /// use ssg::plugin::PluginContext;
255    /// use tempfile::tempdir;
256    ///
257    /// let dir = tempdir().unwrap();
258    /// let mut ctx = PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
259    /// ctx.cache_html_files();
260    /// // Empty dir ⇒ empty cached list.
261    /// assert!(ctx.get_html_files().is_empty());
262    /// ```
263    pub fn cache_html_files(&mut self) {
264        if self.site_dir.exists() {
265            let files = crate::walk::walk_files(&self.site_dir, "html")
266                .unwrap_or_default();
267            self.html_files = Some(Arc::new(files));
268        }
269    }
270
271    /// Returns the cached HTML file list, or walks the directory if
272    /// the cache hasn't been populated.
273    ///
274    /// # Examples
275    ///
276    /// ```rust
277    /// use ssg::plugin::PluginContext;
278    /// use tempfile::tempdir;
279    ///
280    /// let dir = tempdir().unwrap();
281    /// let ctx = PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
282    /// assert!(ctx.get_html_files().is_empty());
283    /// ```
284    #[must_use]
285    pub fn get_html_files(&self) -> Vec<PathBuf> {
286        if let Some(ref cached) = self.html_files {
287            cached.as_ref().clone()
288        } else {
289            crate::walk::walk_files(&self.site_dir, "html").unwrap_or_default()
290        }
291    }
292
293    /// Creates a new plugin context from directory paths.
294    ///
295    /// # Examples
296    ///
297    /// ```rust
298    /// use ssg::plugin::PluginContext;
299    /// use std::path::Path;
300    ///
301    /// let ctx = PluginContext::new(
302    ///     Path::new("content"), Path::new("build"),
303    ///     Path::new("site"),    Path::new("templates"),
304    /// );
305    /// assert_eq!(ctx.content_dir, Path::new("content"));
306    /// ```
307    #[must_use]
308    pub fn new(
309        content_dir: &Path,
310        build_dir: &Path,
311        site_dir: &Path,
312        template_dir: &Path,
313    ) -> Self {
314        Self {
315            content_dir: content_dir.to_path_buf(),
316            build_dir: build_dir.to_path_buf(),
317            site_dir: site_dir.to_path_buf(),
318            template_dir: template_dir.to_path_buf(),
319            config: None,
320            cache: None,
321            memory_budget: None,
322            html_files: None,
323            dep_graph: None,
324            dry_run: false,
325        }
326    }
327
328    /// Creates a new plugin context with site configuration.
329    ///
330    /// # Examples
331    ///
332    /// ```rust
333    /// use ssg::cmd::SsgConfig;
334    /// use ssg::plugin::PluginContext;
335    /// use std::path::Path;
336    ///
337    /// let cfg = SsgConfig::default();
338    /// let ctx = PluginContext::with_config(
339    ///     Path::new("content"), Path::new("build"),
340    ///     Path::new("site"), Path::new("templates"),
341    ///     cfg,
342    /// );
343    /// assert!(ctx.config.is_some());
344    /// ```
345    #[must_use]
346    pub fn with_config(
347        content_dir: &Path,
348        build_dir: &Path,
349        site_dir: &Path,
350        template_dir: &Path,
351        config: SsgConfig,
352    ) -> Self {
353        Self {
354            content_dir: content_dir.to_path_buf(),
355            build_dir: build_dir.to_path_buf(),
356            site_dir: site_dir.to_path_buf(),
357            template_dir: template_dir.to_path_buf(),
358            config: Some(config),
359            cache: None,
360            memory_budget: None,
361            html_files: None,
362            dep_graph: None,
363            dry_run: false,
364        }
365    }
366
367    /// Sets the `dry_run` flag and returns the modified context.
368    ///
369    /// Used by the `ssg check` subcommand (issue #527) to signal to
370    /// plugins that they should run their validation passes without
371    /// writing to disk.
372    ///
373    /// # Examples
374    ///
375    /// ```rust
376    /// use ssg::plugin::PluginContext;
377    /// use std::path::Path;
378    ///
379    /// let ctx = PluginContext::new(
380    ///     Path::new("content"), Path::new("build"),
381    ///     Path::new("site"), Path::new("templates"),
382    /// ).with_dry_run(true);
383    /// assert!(ctx.dry_run);
384    /// ```
385    #[must_use]
386    pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
387        self.dry_run = dry_run;
388        self
389    }
390}
391
392/// Trait for SSG plugins.
393///
394/// Implement this trait to create a plugin that hooks into the site
395/// generation lifecycle. All hooks have default no-op implementations,
396/// so you only need to override the ones you care about.
397///
398/// # Stability contract
399///
400/// This trait is part of the SSG public API. The stability commitment
401/// for the `1.0` line is:
402///
403/// 1. **All current hook signatures are frozen.** Once `1.0` ships, no
404///    parameter, return type, or trait bound on an existing method
405///    will change without a major version bump.
406/// 2. **New hooks land with a default `Ok(())` implementation.**
407///    Adding a new hook is therefore non-breaking — existing
408///    `impl Plugin for …` blocks continue to compile.
409/// 3. **`PluginContext` is `#[non_exhaustive]`.** New fields (e.g.
410///    additional caches, link graphs, image metadata) can be added
411///    without breaking downstream construction sites — those are
412///    constructed inside SSG, not by plugin authors.
413/// 4. **Removing a hook requires a major bump.** Hook removal is rare
414///    and always preceded by a deprecation cycle of at least one
415///    minor release with `#[deprecated]` and a migration note in the
416///    CHANGELOG.
417///
418/// See [API stability audit](../../docs/architecture/api-stability-audit.md)
419/// for the full Tier-A inventory.
420pub trait Plugin: fmt::Debug + Send + Sync {
421    /// Returns the unique name of this plugin.
422    fn name(&self) -> &str;
423
424    /// Called before site compilation begins.
425    ///
426    /// Use this hook to preprocess content files, inject metadata,
427    /// or validate source directories.
428    fn before_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
429        Ok(())
430    }
431
432    /// Called after site compilation completes.
433    ///
434    /// Use this hook to post-process generated HTML, optimize assets,
435    /// generate sitemaps, or perform any output transformation.
436    fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
437        Ok(())
438    }
439
440    /// Per-file HTML transform hook — called once per HTML file during
441    /// the fused transform pass.
442    ///
443    /// Receives the current HTML content and returns the (possibly modified)
444    /// HTML. The default implementation returns the input unchanged.
445    ///
446    /// Plugins that implement this hook avoid redundant file I/O — the
447    /// pipeline reads each HTML file once, pipes it through all plugins'
448    /// `transform_html` hooks, then writes the result once.
449    fn transform_html(
450        &self,
451        html: &str,
452        _path: &Path,
453        _ctx: &PluginContext,
454    ) -> Result<String, SsgError> {
455        Ok(html.to_string())
456    }
457
458    /// Returns `true` if this plugin implements `transform_html`.
459    /// Override to `true` to opt in to the fused transform pass.
460    fn has_transform(&self) -> bool {
461        false
462    }
463
464    /// Returns `true` if this plugin must always observe the full
465    /// `cache.html_files()` list — even during `--incremental`
466    /// rebuilds that only invalidated a handful of pages.
467    ///
468    /// SEO sitemap regeneration, SBOM emission, JSON-LD aggregation,
469    /// and search-index builders all need the complete view of the
470    /// site to produce correct output, so they opt in to `true`
471    /// (the default). Plugins that genuinely work per-file (and so
472    /// can be skipped for unaffected pages) override to `false`.
473    /// (Issue #524 AC7.)
474    fn needs_all_files(&self) -> bool {
475        true
476    }
477
478    /// Called before the development server starts serving files.
479    ///
480    /// Use this hook to inject dev-mode scripts, set up live-reload,
481    /// or modify the serve directory.
482    fn on_serve(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
483        Ok(())
484    }
485}
486
487/// Manages registered plugins and executes lifecycle hooks.
488///
489/// # Example
490///
491/// ```rust
492/// use ssg::plugin::{PluginManager, PluginContext, Plugin};
493/// use ssg::error::SsgError;
494/// use std::path::Path;
495///
496/// #[derive(Debug)]
497/// struct LogPlugin;
498///
499/// impl Plugin for LogPlugin {
500///     fn name(&self) -> &str { "logger" }
501///     fn before_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
502///         println!("Compiling from {:?}", ctx.content_dir);
503///         Ok(())
504///     }
505/// }
506///
507/// let mut pm = PluginManager::new();
508/// pm.register(LogPlugin);
509/// assert_eq!(pm.len(), 1);
510///
511/// let ctx = PluginContext::new(
512///     Path::new("content"),
513///     Path::new("build"),
514///     Path::new("public"),
515///     Path::new("templates"),
516/// );
517/// pm.run_before_compile(&ctx).unwrap();
518/// ```
519#[derive(Debug, Default)]
520pub struct PluginManager {
521    plugins: Vec<Box<dyn Plugin>>,
522}
523
524/// One row of [`PluginManager::inventory`]: a registered plugin and the
525/// optional hooks it opts into.
526///
527/// `order` is the index in registration order, which is also execution
528/// order — the property that makes the taxonomy/pagination phase bug
529/// visible rather than implicit.
530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531pub struct PluginInfo<'a> {
532    /// Position in execution order, starting at zero.
533    pub order: usize,
534    /// The plugin's stable name, as reported by [`Plugin::name`].
535    pub name: &'a str,
536    /// Whether the plugin participates in the fused HTML transform pass.
537    pub has_transform: bool,
538    /// Whether the plugin needs every file present before it runs.
539    pub needs_all_files: bool,
540}
541
542impl PluginManager {
543    /// Creates a new empty plugin manager.
544    ///
545    /// # Examples
546    ///
547    /// ```rust
548    /// use ssg::plugin::PluginManager;
549    ///
550    /// let pm = PluginManager::new();
551    /// assert!(pm.is_empty());
552    /// ```
553    #[must_use]
554    pub fn new() -> Self {
555        Self {
556            plugins: Vec::new(),
557        }
558    }
559
560    /// Registers a plugin.
561    ///
562    /// Plugins run in the order they are registered.
563    ///
564    /// # Examples
565    ///
566    /// ```rust
567    /// use ssg::drafts::DraftPlugin;
568    /// use ssg::plugin::PluginManager;
569    ///
570    /// let mut pm = PluginManager::new();
571    /// pm.register(DraftPlugin::new(false));
572    /// assert_eq!(pm.len(), 1);
573    /// ```
574    pub fn register<P: Plugin + 'static>(&mut self, plugin: P) {
575        self.plugins.push(Box::new(plugin));
576    }
577
578    /// Returns the number of registered plugins.
579    ///
580    /// # Examples
581    ///
582    /// ```rust
583    /// use ssg::plugin::PluginManager;
584    ///
585    /// let pm = PluginManager::new();
586    /// assert_eq!(pm.len(), 0);
587    /// ```
588    #[must_use]
589    pub fn len(&self) -> usize {
590        self.plugins.len()
591    }
592
593    /// Returns `true` if no plugins are registered.
594    ///
595    /// # Examples
596    ///
597    /// ```rust
598    /// use ssg::plugin::PluginManager;
599    ///
600    /// assert!(PluginManager::new().is_empty());
601    /// ```
602    #[must_use]
603    pub fn is_empty(&self) -> bool {
604        self.plugins.is_empty()
605    }
606
607    /// Returns the names of all registered plugins.
608    ///
609    /// # Examples
610    ///
611    /// ```rust
612    /// use ssg::drafts::DraftPlugin;
613    /// use ssg::plugin::PluginManager;
614    ///
615    /// let mut pm = PluginManager::new();
616    /// pm.register(DraftPlugin::new(false));
617    /// assert_eq!(pm.names(), vec!["drafts"]);
618    /// ```
619    #[must_use]
620    pub fn names(&self) -> Vec<&str> {
621        self.plugins.iter().map(|p| p.name()).collect()
622    }
623
624    /// Returns an inventory of every registered plugin, in execution order.
625    ///
626    /// This is the single source of truth for "which plugins run, and what do
627    /// they do" — consumed by `ssg plugins list` and by the README sync check,
628    /// so a plugin added or removed cannot silently leave the documentation
629    /// claiming a stale count.
630    ///
631    /// # Examples
632    ///
633    /// ```rust
634    /// use ssg::drafts::DraftPlugin;
635    /// use ssg::plugin::PluginManager;
636    ///
637    /// let mut pm = PluginManager::new();
638    /// pm.register(DraftPlugin::new(false));
639    ///
640    /// let inv = pm.inventory();
641    /// assert_eq!(inv.len(), 1);
642    /// assert_eq!(inv[0].name, "drafts");
643    /// assert_eq!(inv[0].order, 0);
644    /// ```
645    #[must_use]
646    pub fn inventory(&self) -> Vec<PluginInfo<'_>> {
647        self.plugins
648            .iter()
649            .enumerate()
650            .map(|(order, p)| PluginInfo {
651                order,
652                name: p.name(),
653                has_transform: p.has_transform(),
654                needs_all_files: p.needs_all_files(),
655            })
656            .collect()
657    }
658
659    /// Runs the `before_compile` hook on all registered plugins.
660    ///
661    /// Plugins execute in registration order. If any plugin returns
662    /// an error, execution stops and the error is propagated.
663    ///
664    /// # Examples
665    ///
666    /// ```rust
667    /// use ssg::plugin::{PluginContext, PluginManager};
668    /// use std::path::Path;
669    ///
670    /// let pm = PluginManager::new();
671    /// let ctx = PluginContext::new(
672    ///     Path::new("content"), Path::new("build"),
673    ///     Path::new("site"), Path::new("templates"),
674    /// );
675    /// assert!(pm.run_before_compile(&ctx).is_ok());
676    /// ```
677    pub fn run_before_compile(
678        &self,
679        ctx: &PluginContext,
680    ) -> Result<(), SsgError> {
681        for plugin in &self.plugins {
682            plugin.before_compile(ctx)?;
683        }
684        Ok(())
685    }
686
687    /// Runs the `after_compile` hook on all registered plugins.
688    ///
689    /// Plugins execute in registration order. If any plugin returns
690    /// an error, execution stops and the error is propagated.
691    ///
692    /// # Examples
693    ///
694    /// ```rust
695    /// use ssg::plugin::{PluginContext, PluginManager};
696    /// use std::path::Path;
697    ///
698    /// let pm = PluginManager::new();
699    /// let ctx = PluginContext::new(
700    ///     Path::new("content"), Path::new("build"),
701    ///     Path::new("site"), Path::new("templates"),
702    /// );
703    /// assert!(pm.run_after_compile(&ctx).is_ok());
704    /// ```
705    pub fn run_after_compile(
706        &self,
707        ctx: &PluginContext,
708    ) -> Result<(), SsgError> {
709        for plugin in &self.plugins {
710            plugin.after_compile(ctx)?;
711        }
712        Ok(())
713    }
714
715    /// Runs the fused HTML transform pass: reads each HTML file once,
716    /// pipes through all plugins with `has_transform() == true`, writes once.
717    ///
718    /// This eliminates N separate read/write cycles (where N = number of
719    /// transform plugins) per HTML file.
720    ///
721    /// # Examples
722    ///
723    /// ```rust
724    /// use ssg::plugin::{PluginContext, PluginManager};
725    /// use std::path::Path;
726    ///
727    /// let pm = PluginManager::new();
728    /// let ctx = PluginContext::new(
729    ///     Path::new("content"), Path::new("build"),
730    ///     Path::new("site"), Path::new("templates"),
731    /// );
732    /// // No transform plugins ⇒ trivial Ok.
733    /// assert!(pm.run_fused_transforms(&ctx).is_ok());
734    /// ```
735    pub fn run_fused_transforms(
736        &self,
737        ctx: &PluginContext,
738    ) -> Result<(), SsgError> {
739        use rayon::prelude::*;
740
741        let transform_plugins: Vec<_> =
742            self.plugins.iter().filter(|p| p.has_transform()).collect();
743
744        if transform_plugins.is_empty() {
745            return Ok(());
746        }
747
748        let html_files = ctx.get_html_files();
749        let transformed = std::sync::atomic::AtomicUsize::new(0);
750
751        // Writer pool (issue #569 phase 1): rayon workers hand changed
752        // files to dedicated writer threads instead of blocking a CPU
753        // slot on `fs::write`. Unchanged files are skipped entirely —
754        // on a no-op rebuild this pass writes zero files.
755        let io_pool = crate::io_pool::IoPool::new();
756
757        html_files
758            .par_iter()
759            .try_for_each(|path| -> Result<(), SsgError> {
760                let original = fs::read_to_string(path).with_path(path)?;
761                let mut html = original.clone();
762
763                for plugin in &transform_plugins {
764                    html = plugin.transform_html(&html, path, ctx)?;
765                }
766
767                if html != original {
768                    io_pool.write(path, html.into_bytes())?;
769                    let _ = transformed
770                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
771                }
772                Ok(())
773            })?;
774
775        // Barrier: everything after this pass (dep-graph repopulation
776        // and the plugin content-hash cache rebuild in
777        // `pipeline::compile_with_plugins`, dev-server reads, audits)
778        // re-reads the transformed files from disk, so all queued
779        // writes must be durably complete — and any write failure
780        // surfaced — before this function returns.
781        io_pool.flush()?;
782
783        let count = transformed.load(std::sync::atomic::Ordering::Relaxed);
784        if count > 0 {
785            log::info!(
786                "[pipeline] Fused transform: {count} file(s), {} plugin(s)",
787                transform_plugins.len()
788            );
789        }
790        Ok(())
791    }
792
793    /// Runs the `on_serve` hook on all registered plugins.
794    ///
795    /// Plugins execute in registration order. If any plugin returns
796    /// an error, execution stops and the error is propagated.
797    ///
798    /// # Examples
799    ///
800    /// ```rust
801    /// use ssg::plugin::{PluginContext, PluginManager};
802    /// use std::path::Path;
803    ///
804    /// let pm = PluginManager::new();
805    /// let ctx = PluginContext::new(
806    ///     Path::new("content"), Path::new("build"),
807    ///     Path::new("site"), Path::new("templates"),
808    /// );
809    /// assert!(pm.run_on_serve(&ctx).is_ok());
810    /// ```
811    pub fn run_on_serve(&self, ctx: &PluginContext) -> Result<(), SsgError> {
812        for plugin in &self.plugins {
813            plugin.on_serve(ctx)?;
814        }
815        Ok(())
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use std::sync::atomic::{AtomicUsize, Ordering};
823
824    #[derive(Debug)]
825    struct CounterPlugin {
826        name: &'static str,
827        before: &'static AtomicUsize,
828        after: &'static AtomicUsize,
829        serve: &'static AtomicUsize,
830    }
831
832    impl Plugin for CounterPlugin {
833        fn name(&self) -> &str {
834            self.name
835        }
836        fn before_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
837            let _ = self.before.fetch_add(1, Ordering::SeqCst);
838            Ok(())
839        }
840        fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
841            let _ = self.after.fetch_add(1, Ordering::SeqCst);
842            Ok(())
843        }
844        fn on_serve(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
845            let _ = self.serve.fetch_add(1, Ordering::SeqCst);
846            Ok(())
847        }
848    }
849
850    #[derive(Debug)]
851    struct FailPlugin {
852        hook: &'static str,
853    }
854
855    impl Plugin for FailPlugin {
856        fn name(&self) -> &'static str {
857            "fail-plugin"
858        }
859        fn before_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
860            if self.hook == "before" {
861                return Err(SsgError::Io {
862                    path: PathBuf::from("fail"),
863                    source: std::io::Error::other("before_compile failed"),
864                });
865            }
866            Ok(())
867        }
868        fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
869            if self.hook == "after" {
870                return Err(SsgError::Io {
871                    path: PathBuf::from("fail"),
872                    source: std::io::Error::other("after_compile failed"),
873                });
874            }
875            Ok(())
876        }
877        fn on_serve(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
878            if self.hook == "serve" {
879                return Err(SsgError::Io {
880                    path: PathBuf::from("fail"),
881                    source: std::io::Error::other("on_serve failed"),
882                });
883            }
884            Ok(())
885        }
886    }
887
888    #[derive(Debug)]
889    struct NoopPlugin;
890
891    impl Plugin for NoopPlugin {
892        fn name(&self) -> &'static str {
893            "noop"
894        }
895    }
896
897    fn test_ctx() -> PluginContext {
898        PluginContext::new(
899            Path::new("content"),
900            Path::new("build"),
901            Path::new("public"),
902            Path::new("templates"),
903        )
904    }
905
906    #[test]
907    fn test_plugin_manager_new_is_empty() {
908        let pm = PluginManager::new();
909        assert!(pm.is_empty());
910        assert_eq!(pm.len(), 0);
911        assert!(pm.names().is_empty());
912    }
913
914    #[test]
915    fn test_plugin_manager_default() {
916        let pm = PluginManager::default();
917        assert!(pm.is_empty());
918    }
919
920    #[test]
921    fn test_register_and_count() {
922        let mut pm = PluginManager::new();
923        pm.register(NoopPlugin);
924        assert_eq!(pm.len(), 1);
925        assert!(!pm.is_empty());
926        assert_eq!(pm.names(), vec!["noop"]);
927    }
928
929    #[test]
930    fn test_multiple_plugins_run_in_order() {
931        static BEFORE_A: AtomicUsize = AtomicUsize::new(0);
932        static AFTER_A: AtomicUsize = AtomicUsize::new(0);
933        static SERVE_A: AtomicUsize = AtomicUsize::new(0);
934        static BEFORE_B: AtomicUsize = AtomicUsize::new(0);
935        static AFTER_B: AtomicUsize = AtomicUsize::new(0);
936        static SERVE_B: AtomicUsize = AtomicUsize::new(0);
937
938        let mut pm = PluginManager::new();
939        pm.register(CounterPlugin {
940            name: "a",
941            before: &BEFORE_A,
942            after: &AFTER_A,
943            serve: &SERVE_A,
944        });
945        pm.register(CounterPlugin {
946            name: "b",
947            before: &BEFORE_B,
948            after: &AFTER_B,
949            serve: &SERVE_B,
950        });
951
952        let ctx = test_ctx();
953        pm.run_before_compile(&ctx).unwrap();
954        pm.run_after_compile(&ctx).unwrap();
955        pm.run_on_serve(&ctx).unwrap();
956
957        assert_eq!(BEFORE_A.load(Ordering::SeqCst), 1);
958        assert_eq!(BEFORE_B.load(Ordering::SeqCst), 1);
959        assert_eq!(AFTER_A.load(Ordering::SeqCst), 1);
960        assert_eq!(AFTER_B.load(Ordering::SeqCst), 1);
961        assert_eq!(SERVE_A.load(Ordering::SeqCst), 1);
962        assert_eq!(SERVE_B.load(Ordering::SeqCst), 1);
963        assert_eq!(pm.names(), vec!["a", "b"]);
964    }
965
966    #[test]
967    fn test_noop_plugin_all_hooks_succeed() {
968        let mut pm = PluginManager::new();
969        pm.register(NoopPlugin);
970        let ctx = test_ctx();
971        assert!(pm.run_before_compile(&ctx).is_ok());
972        assert!(pm.run_after_compile(&ctx).is_ok());
973        assert!(pm.run_on_serve(&ctx).is_ok());
974    }
975
976    #[test]
977    fn test_before_compile_error_propagates() {
978        let mut pm = PluginManager::new();
979        pm.register(FailPlugin { hook: "before" });
980        let ctx = test_ctx();
981        let err = pm.run_before_compile(&ctx).unwrap_err();
982        // Debug output carries both the variant and the source message,
983        // asserting the same facts as a `matches!` + field check without
984        // an uncoverable fallthrough arm.
985        let dbg = format!("{err:?}");
986        assert!(dbg.contains("Io"), "expected Io variant, got: {dbg}");
987        assert!(
988            dbg.contains("before_compile failed"),
989            "source message expected: {dbg}"
990        );
991    }
992
993    #[test]
994    fn test_after_compile_error_propagates() {
995        let mut pm = PluginManager::new();
996        pm.register(FailPlugin { hook: "after" });
997        let ctx = test_ctx();
998        let err = pm.run_after_compile(&ctx).unwrap_err();
999        // Debug output carries both the variant and the source message,
1000        // asserting the same facts as a `matches!` + field check without
1001        // an uncoverable fallthrough arm.
1002        let dbg = format!("{err:?}");
1003        assert!(dbg.contains("Io"), "expected Io variant, got: {dbg}");
1004        assert!(
1005            dbg.contains("after_compile failed"),
1006            "source message expected: {dbg}"
1007        );
1008    }
1009
1010    #[test]
1011    fn test_on_serve_error_propagates() {
1012        let mut pm = PluginManager::new();
1013        pm.register(FailPlugin { hook: "serve" });
1014        let ctx = test_ctx();
1015        let err = pm.run_on_serve(&ctx).unwrap_err();
1016        // Debug output carries both the variant and the source message,
1017        // asserting the same facts as a `matches!` + field check without
1018        // an uncoverable fallthrough arm.
1019        let dbg = format!("{err:?}");
1020        assert!(dbg.contains("Io"), "expected Io variant, got: {dbg}");
1021        assert!(
1022            dbg.contains("on_serve failed"),
1023            "source message expected: {dbg}"
1024        );
1025    }
1026
1027    #[test]
1028    fn test_error_stops_subsequent_plugins() {
1029        static COUNTER: AtomicUsize = AtomicUsize::new(0);
1030
1031        let mut pm = PluginManager::new();
1032        pm.register(FailPlugin { hook: "before" });
1033        pm.register(CounterPlugin {
1034            name: "second",
1035            before: &COUNTER,
1036            after: &COUNTER,
1037            serve: &COUNTER,
1038        });
1039
1040        let ctx = test_ctx();
1041        assert!(pm.run_before_compile(&ctx).is_err());
1042        // Second plugin should not have run
1043        assert_eq!(COUNTER.load(Ordering::SeqCst), 0);
1044    }
1045
1046    #[test]
1047    fn test_empty_manager_hooks_succeed() {
1048        let pm = PluginManager::new();
1049        let ctx = test_ctx();
1050        assert!(pm.run_before_compile(&ctx).is_ok());
1051        assert!(pm.run_after_compile(&ctx).is_ok());
1052        assert!(pm.run_on_serve(&ctx).is_ok());
1053    }
1054
1055    #[test]
1056    fn test_plugin_context_fields() {
1057        let ctx = PluginContext::new(
1058            Path::new("/a"),
1059            Path::new("/b"),
1060            Path::new("/c"),
1061            Path::new("/d"),
1062        );
1063        assert_eq!(ctx.content_dir, PathBuf::from("/a"));
1064        assert_eq!(ctx.build_dir, PathBuf::from("/b"));
1065        assert_eq!(ctx.site_dir, PathBuf::from("/c"));
1066        assert_eq!(ctx.template_dir, PathBuf::from("/d"));
1067    }
1068
1069    #[test]
1070    fn test_plugin_context_clone() {
1071        let ctx = test_ctx();
1072        let cloned = ctx.clone();
1073        assert_eq!(ctx.content_dir, cloned.content_dir);
1074        assert_eq!(ctx.site_dir, cloned.site_dir);
1075    }
1076
1077    #[test]
1078    fn test_plugin_context_debug() {
1079        let ctx = test_ctx();
1080        let debug = format!("{ctx:?}");
1081        assert!(debug.contains("content"));
1082        assert!(debug.contains("build"));
1083    }
1084
1085    #[test]
1086    fn test_plugin_manager_debug() {
1087        let mut pm = PluginManager::new();
1088        pm.register(NoopPlugin);
1089        let debug = format!("{pm:?}");
1090        assert!(debug.contains("NoopPlugin"));
1091    }
1092
1093    // -----------------------------------------------------------------
1094    // PluginCache tests
1095    // -----------------------------------------------------------------
1096
1097    #[test]
1098    fn test_cache_new_is_empty() {
1099        let cache = PluginCache::new();
1100        assert!(cache.entries.is_empty());
1101    }
1102
1103    #[test]
1104    fn test_cache_has_changed_on_missing_entry() {
1105        let tmp = tempfile::tempdir().unwrap();
1106        let file = tmp.path().join("hello.txt");
1107        fs::write(&file, "hello").unwrap();
1108
1109        let cache = PluginCache::new();
1110        assert!(cache.has_changed(&file), "New file should count as changed");
1111    }
1112
1113    #[test]
1114    fn test_cache_has_changed_detects_unchanged() {
1115        let tmp = tempfile::tempdir().unwrap();
1116        let file = tmp.path().join("hello.txt");
1117        fs::write(&file, "hello").unwrap();
1118
1119        let mut cache = PluginCache::new();
1120        cache.update(&file);
1121        assert!(
1122            !cache.has_changed(&file),
1123            "File should not be changed after update"
1124        );
1125    }
1126
1127    #[test]
1128    fn test_cache_has_changed_detects_modification() {
1129        let tmp = tempfile::tempdir().unwrap();
1130        let file = tmp.path().join("hello.txt");
1131        fs::write(&file, "hello").unwrap();
1132
1133        let mut cache = PluginCache::new();
1134        cache.update(&file);
1135
1136        // Modify the file
1137        fs::write(&file, "world").unwrap();
1138        assert!(
1139            cache.has_changed(&file),
1140            "Modified file should be detected as changed"
1141        );
1142    }
1143
1144    #[test]
1145    fn test_cache_persistence_save_load() {
1146        let tmp = tempfile::tempdir().unwrap();
1147        let file = tmp.path().join("data.txt");
1148        fs::write(&file, "content").unwrap();
1149
1150        let mut cache = PluginCache::new();
1151        cache.update(&file);
1152        cache.save(tmp.path()).unwrap();
1153
1154        // Verify the cache file exists
1155        let cache_path = tmp.path().join(CACHE_FILENAME);
1156        assert!(cache_path.exists(), "Cache file should be persisted");
1157
1158        // Load it back
1159        let loaded = PluginCache::load(tmp.path());
1160        assert!(
1161            !loaded.has_changed(&file),
1162            "Loaded cache should still recognise unchanged file"
1163        );
1164    }
1165
1166    #[test]
1167    fn test_cache_load_missing_file() {
1168        let tmp = tempfile::tempdir().unwrap();
1169        let cache = PluginCache::load(tmp.path());
1170        assert!(cache.entries.is_empty());
1171    }
1172
1173    #[test]
1174    fn test_cache_has_changed_nonexistent_file() {
1175        let cache = PluginCache::new();
1176        assert!(
1177            cache.has_changed(Path::new("/nonexistent/file.txt")),
1178            "Nonexistent file should count as changed"
1179        );
1180    }
1181
1182    // -----------------------------------------------------------------
1183    // PluginCache: save/load round-trip, hash determinism, empty cache
1184    // -----------------------------------------------------------------
1185
1186    #[test]
1187    fn test_cache_save_load_round_trip_with_multiple_files() {
1188        let tmp = tempfile::tempdir().unwrap();
1189        let f1 = tmp.path().join("one.txt");
1190        let f2 = tmp.path().join("two.txt");
1191        fs::write(&f1, "alpha").unwrap();
1192        fs::write(&f2, "beta").unwrap();
1193
1194        let mut cache = PluginCache::new();
1195        cache.update(&f1);
1196        cache.update(&f2);
1197        cache.save(tmp.path()).unwrap();
1198
1199        let loaded = PluginCache::load(tmp.path());
1200        assert!(!loaded.has_changed(&f1));
1201        assert!(!loaded.has_changed(&f2));
1202    }
1203
1204    #[test]
1205    fn test_cache_empty_save_load() {
1206        let tmp = tempfile::tempdir().unwrap();
1207        let cache = PluginCache::new();
1208        cache.save(tmp.path()).unwrap();
1209
1210        let loaded = PluginCache::load(tmp.path());
1211        assert!(loaded.entries.is_empty());
1212    }
1213
1214    #[test]
1215    fn test_cache_hash_bytes_determinism() {
1216        let data = b"hello world";
1217        let h1 = PluginCache::hash_bytes(data);
1218        let h2 = PluginCache::hash_bytes(data);
1219        assert_eq!(h1, h2, "same input must produce same hash");
1220    }
1221
1222    #[test]
1223    fn test_cache_hash_bytes_different_inputs() {
1224        let h1 = PluginCache::hash_bytes(b"aaa");
1225        let h2 = PluginCache::hash_bytes(b"bbb");
1226        assert_ne!(h1, h2, "different inputs should produce different hashes");
1227    }
1228
1229    #[test]
1230    fn test_cache_hash_bytes_empty() {
1231        // Empty input should return the FNV offset basis
1232        let h = PluginCache::hash_bytes(b"");
1233        assert_eq!(h, 0xcbf2_9ce4_8422_2325);
1234    }
1235
1236    #[test]
1237    fn test_cache_has_changed_after_file_modification() {
1238        let tmp = tempfile::tempdir().unwrap();
1239        let f = tmp.path().join("data.txt");
1240        fs::write(&f, "version1").unwrap();
1241
1242        let mut cache = PluginCache::new();
1243        cache.update(&f);
1244        assert!(!cache.has_changed(&f));
1245
1246        // Modify file content
1247        fs::write(&f, "version2").unwrap();
1248        assert!(cache.has_changed(&f));
1249
1250        // Update cache, should no longer be changed
1251        cache.update(&f);
1252        assert!(!cache.has_changed(&f));
1253    }
1254
1255    #[test]
1256    fn test_cache_load_corrupt_json() {
1257        let tmp = tempfile::tempdir().unwrap();
1258        let cache_path = tmp.path().join(CACHE_FILENAME);
1259        fs::write(&cache_path, "this is not json").unwrap();
1260
1261        let loaded = PluginCache::load(tmp.path());
1262        assert!(
1263            loaded.entries.is_empty(),
1264            "corrupt JSON should yield empty cache"
1265        );
1266    }
1267
1268    #[test]
1269    fn test_cache_update_nonexistent_file_is_noop() {
1270        let mut cache = PluginCache::new();
1271        cache.update(Path::new("/nonexistent/file.txt"));
1272        assert!(cache.entries.is_empty());
1273    }
1274
1275    #[test]
1276    fn test_cache_default_is_empty() {
1277        let cache = PluginCache::default();
1278        assert!(cache.entries.is_empty());
1279    }
1280
1281    #[test]
1282    fn test_cache_clone() {
1283        let tmp = tempfile::tempdir().unwrap();
1284        let f = tmp.path().join("x.txt");
1285        fs::write(&f, "x").unwrap();
1286
1287        let mut cache = PluginCache::new();
1288        cache.update(&f);
1289
1290        let cloned = cache.clone();
1291        assert!(!cloned.has_changed(&f));
1292    }
1293
1294    #[test]
1295    fn test_plugin_context_with_config() {
1296        let config = SsgConfig::builder()
1297            .site_name("test".to_string())
1298            .base_url("https://example.com".to_string())
1299            .build()
1300            .expect("config");
1301        let ctx = PluginContext::with_config(
1302            Path::new("c"),
1303            Path::new("b"),
1304            Path::new("s"),
1305            Path::new("t"),
1306            config,
1307        );
1308        assert!(ctx.config.is_some());
1309        assert_eq!(ctx.config.unwrap().site_name, "test");
1310    }
1311
1312    #[test]
1313    fn test_needs_all_files_defaults_to_true() {
1314        // Issue #524 AC7: the default is conservative — every plugin
1315        // sees the full file list unless it explicitly opts out.
1316        let p = NoopPlugin;
1317        assert!(p.needs_all_files());
1318    }
1319
1320    #[derive(Debug)]
1321    struct PerFilePlugin;
1322    impl Plugin for PerFilePlugin {
1323        fn name(&self) -> &'static str {
1324            "per-file"
1325        }
1326        fn needs_all_files(&self) -> bool {
1327            false
1328        }
1329    }
1330
1331    #[test]
1332    fn test_needs_all_files_can_be_overridden() {
1333        assert!(!PerFilePlugin.needs_all_files());
1334        assert_eq!(PerFilePlugin.name(), "per-file");
1335    }
1336
1337    #[test]
1338    fn test_fail_plugin_non_matching_hooks_succeed() {
1339        let ctx = test_ctx();
1340
1341        // FailPlugin("before") should succeed on after_compile and on_serve
1342        let p = FailPlugin { hook: "before" };
1343        assert_eq!(p.name(), "fail-plugin");
1344        assert!(p.after_compile(&ctx).is_ok());
1345        assert!(p.on_serve(&ctx).is_ok());
1346
1347        // FailPlugin("after") should succeed on before_compile and on_serve
1348        let p = FailPlugin { hook: "after" };
1349        assert!(p.before_compile(&ctx).is_ok());
1350        assert!(p.on_serve(&ctx).is_ok());
1351
1352        // FailPlugin("serve") should succeed on before_compile and after_compile
1353        let p = FailPlugin { hook: "serve" };
1354        assert!(p.before_compile(&ctx).is_ok());
1355        assert!(p.after_compile(&ctx).is_ok());
1356    }
1357
1358    /// Transform plugin that returns the input unchanged (but as a
1359    /// fresh `String`, like real plugins that found nothing to do).
1360    #[derive(Debug)]
1361    struct IdentityTransformPlugin;
1362    impl Plugin for IdentityTransformPlugin {
1363        fn name(&self) -> &'static str {
1364            "identity-transform"
1365        }
1366        fn transform_html(
1367            &self,
1368            html: &str,
1369            _path: &Path,
1370            _ctx: &PluginContext,
1371        ) -> Result<String, SsgError> {
1372            Ok(html.to_string())
1373        }
1374        fn has_transform(&self) -> bool {
1375            true
1376        }
1377    }
1378
1379    /// Transform plugin that rewrites a marker when present.
1380    #[derive(Debug)]
1381    struct MarkerRewritePlugin;
1382    impl Plugin for MarkerRewritePlugin {
1383        fn name(&self) -> &'static str {
1384            "marker-rewrite"
1385        }
1386        fn transform_html(
1387            &self,
1388            html: &str,
1389            _path: &Path,
1390            _ctx: &PluginContext,
1391        ) -> Result<String, SsgError> {
1392            Ok(html.replace("CHANGE-ME", "CHANGED"))
1393        }
1394        fn has_transform(&self) -> bool {
1395            true
1396        }
1397    }
1398
1399    /// Makes `path` read-only so any attempted rewrite fails loudly.
1400    #[allow(clippy::permissions_set_readonly_false)] // test cleanup only
1401    fn set_readonly(path: &Path, readonly: bool) {
1402        let mut perms = fs::metadata(path).unwrap().permissions();
1403        perms.set_readonly(readonly);
1404        fs::set_permissions(path, perms).unwrap();
1405    }
1406
1407    #[test]
1408    fn test_fused_noop_chain_rewrites_zero_files() {
1409        // Plan §4 3.2: on a no-op rebuild the transform pass must
1410        // write 0 files. The files are made read-only, so if the
1411        // pass attempted any write it would surface as an Err from
1412        // the IoPool flush barrier.
1413        let dir = tempfile::tempdir().unwrap();
1414        let files: Vec<_> = (0..3)
1415            .map(|i| {
1416                let f = dir.path().join(format!("p{i}.html"));
1417                fs::write(&f, format!("<p>page {i}</p>")).unwrap();
1418                set_readonly(&f, true);
1419                f
1420            })
1421            .collect();
1422
1423        assert_eq!(IdentityTransformPlugin.name(), "identity-transform");
1424        assert_eq!(MarkerRewritePlugin.name(), "marker-rewrite");
1425
1426        let mut pm = PluginManager::new();
1427        pm.register(IdentityTransformPlugin);
1428        pm.register(MarkerRewritePlugin); // no marker present ⇒ no-op
1429
1430        let mut ctx =
1431            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
1432        ctx.cache_html_files();
1433
1434        // Zero writes ⇒ Ok even though every file is read-only.
1435        pm.run_fused_transforms(&ctx).unwrap();
1436
1437        for (i, f) in files.iter().enumerate() {
1438            assert_eq!(
1439                fs::read_to_string(f).unwrap(),
1440                format!("<p>page {i}</p>")
1441            );
1442            set_readonly(f, false); // restore for tempdir cleanup
1443        }
1444    }
1445
1446    #[test]
1447    fn test_fused_modifying_chain_writes_exactly_changed_files() {
1448        // The file containing the marker is rewritten; the untouched
1449        // file stays byte-identical AND is read-only — proving the
1450        // pass wrote exactly the changed set.
1451        let dir = tempfile::tempdir().unwrap();
1452        let changed = dir.path().join("changed.html");
1453        let untouched = dir.path().join("untouched.html");
1454        fs::write(&changed, "<p>CHANGE-ME</p>").unwrap();
1455        fs::write(&untouched, "<p>static</p>").unwrap();
1456        set_readonly(&untouched, true);
1457
1458        let mut pm = PluginManager::new();
1459        pm.register(MarkerRewritePlugin);
1460
1461        let mut ctx =
1462            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
1463        ctx.cache_html_files();
1464
1465        pm.run_fused_transforms(&ctx).unwrap();
1466
1467        // Barrier semantics: the rewritten bytes are visible on disk
1468        // immediately after run_fused_transforms returns.
1469        assert_eq!(fs::read_to_string(&changed).unwrap(), "<p>CHANGED</p>");
1470        assert_eq!(fs::read_to_string(&untouched).unwrap(), "<p>static</p>");
1471        set_readonly(&untouched, false);
1472    }
1473
1474    #[test]
1475    fn test_fused_transform_write_failure_surfaces_at_flush() {
1476        // A rewrite aimed at a read-only file must produce an error,
1477        // not silently drop the write (IoPool flush barrier).
1478        let dir = tempfile::tempdir().unwrap();
1479        let f = dir.path().join("locked.html");
1480        fs::write(&f, "<p>CHANGE-ME</p>").unwrap();
1481        set_readonly(&f, true);
1482
1483        let mut pm = PluginManager::new();
1484        pm.register(MarkerRewritePlugin);
1485
1486        let mut ctx =
1487            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
1488        ctx.cache_html_files();
1489
1490        let err = pm
1491            .run_fused_transforms(&ctx)
1492            .expect_err("write to read-only file must surface");
1493        let dbg = format!("{err:?}");
1494        assert!(dbg.contains("Io"), "expected Io variant, got: {dbg}");
1495        set_readonly(&f, false);
1496    }
1497
1498    // -----------------------------------------------------------------
1499    // PluginCache — degraded-input branches
1500    // -----------------------------------------------------------------
1501
1502    #[test]
1503    fn test_cache_load_unreadable_file_yields_empty_cache() {
1504        // Invalid UTF-8 bytes: the file exists but read_to_string fails,
1505        // taking the `let Ok(content) = … else` fallback.
1506        let tmp = tempfile::tempdir().unwrap();
1507        let cache_path = tmp.path().join(CACHE_FILENAME);
1508        fs::write(&cache_path, [0xFF, 0xFE, 0xFD]).unwrap();
1509
1510        let loaded = PluginCache::load(tmp.path());
1511        assert!(
1512            loaded.entries.is_empty(),
1513            "unreadable cache file should yield an empty cache"
1514        );
1515    }
1516
1517    #[test]
1518    fn test_cache_save_write_failure_returns_io_error() {
1519        // A directory squatting on the cache filename makes fs::write fail.
1520        let tmp = tempfile::tempdir().unwrap();
1521        fs::create_dir(tmp.path().join(CACHE_FILENAME)).unwrap();
1522
1523        let err = PluginCache::new()
1524            .save(tmp.path())
1525            .expect_err("write over a directory must fail");
1526        let dbg = format!("{err:?}");
1527        assert!(dbg.contains("Io"), "expected Io variant, got: {dbg}");
1528    }
1529
1530    // -----------------------------------------------------------------
1531    // PluginContext::cache_html_files — missing site_dir branch
1532    // -----------------------------------------------------------------
1533
1534    #[test]
1535    fn test_cache_html_files_missing_site_dir_leaves_cache_unset() {
1536        let tmp = tempfile::tempdir().unwrap();
1537        let missing = tmp.path().join("missing-site");
1538        let mut ctx =
1539            PluginContext::new(tmp.path(), tmp.path(), &missing, tmp.path());
1540        ctx.cache_html_files();
1541        assert!(
1542            ctx.html_files.is_none(),
1543            "missing site_dir must not populate the html cache"
1544        );
1545    }
1546
1547    #[cfg(unix)]
1548    #[test]
1549    fn test_cache_html_files_walk_error_yields_empty_cached_list() {
1550        // `site_dir` exists (so the `exists()` guard passes) but a
1551        // nested unreadable subdirectory makes `walk::walk_files`
1552        // return `Err`, exercising the `unwrap_or_default()` failure
1553        // arm rather than the empty-dir success arm covered elsewhere.
1554        use std::os::unix::fs::PermissionsExt;
1555
1556        let tmp = tempfile::tempdir().unwrap();
1557        let site = tmp.path().join("site");
1558        let locked = site.join("locked");
1559        fs::create_dir_all(&locked).unwrap();
1560        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1561            .unwrap();
1562
1563        let mut ctx =
1564            PluginContext::new(tmp.path(), tmp.path(), &site, tmp.path());
1565        ctx.cache_html_files();
1566
1567        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
1568            .unwrap();
1569        assert_eq!(
1570            ctx.html_files.as_deref(),
1571            Some(&Vec::new()),
1572            "walk error must degrade to an empty cached list, not panic"
1573        );
1574    }
1575
1576    #[cfg(unix)]
1577    #[test]
1578    fn test_get_html_files_walk_error_returns_empty_uncached() {
1579        // Same failure as above but taken through `get_html_files()`'s
1580        // own `unwrap_or_default()` fallback (the `html_files` cache
1581        // was never populated, so it re-walks and must degrade
1582        // gracefully rather than propagating the error or panicking).
1583        use std::os::unix::fs::PermissionsExt;
1584
1585        let tmp = tempfile::tempdir().unwrap();
1586        let site = tmp.path().join("site");
1587        let locked = site.join("locked");
1588        fs::create_dir_all(&locked).unwrap();
1589        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1590            .unwrap();
1591
1592        let ctx = PluginContext::new(tmp.path(), tmp.path(), &site, tmp.path());
1593        let files = ctx.get_html_files();
1594
1595        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
1596            .unwrap();
1597        assert!(files.is_empty(), "walk error must yield an empty Vec");
1598    }
1599
1600    // -----------------------------------------------------------------
1601    // Plugin trait — default transform_html implementation
1602    // -----------------------------------------------------------------
1603
1604    #[test]
1605    fn test_default_transform_html_returns_input_unchanged() {
1606        let ctx = test_ctx();
1607        let out = NoopPlugin
1608            .transform_html("<p>as-is</p>", Path::new("x.html"), &ctx)
1609            .unwrap();
1610        assert_eq!(out, "<p>as-is</p>");
1611    }
1612
1613    // -----------------------------------------------------------------
1614    // run_fused_transforms — early-return + error paths
1615    // -----------------------------------------------------------------
1616
1617    #[test]
1618    fn test_fused_without_transform_plugins_is_trivial_ok() {
1619        // NoopPlugin has has_transform() == false, so the pass exits
1620        // before touching the filesystem.
1621        let mut pm = PluginManager::new();
1622        pm.register(NoopPlugin);
1623        let ctx = test_ctx();
1624        pm.run_fused_transforms(&ctx).unwrap();
1625    }
1626
1627    #[test]
1628    fn test_fused_read_failure_on_invalid_utf8_surfaces() {
1629        let dir = tempfile::tempdir().unwrap();
1630        fs::write(dir.path().join("broken.html"), [0xFF, 0xFE, 0xFD]).unwrap();
1631
1632        let mut pm = PluginManager::new();
1633        pm.register(IdentityTransformPlugin);
1634
1635        let mut ctx =
1636            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
1637        ctx.cache_html_files();
1638
1639        let err = pm
1640            .run_fused_transforms(&ctx)
1641            .expect_err("invalid UTF-8 html must surface a read error");
1642        let dbg = format!("{err:?}");
1643        assert!(dbg.contains("broken.html"), "path context expected: {dbg}");
1644    }
1645
1646    /// Transform plugin whose hook always fails.
1647    #[derive(Debug)]
1648    struct FailingTransformPlugin;
1649    impl Plugin for FailingTransformPlugin {
1650        fn name(&self) -> &'static str {
1651            "failing-transform"
1652        }
1653        fn transform_html(
1654            &self,
1655            _html: &str,
1656            path: &Path,
1657            _ctx: &PluginContext,
1658        ) -> Result<String, SsgError> {
1659            Err(SsgError::Io {
1660                path: path.to_path_buf(),
1661                source: std::io::Error::other("transform_html failed"),
1662            })
1663        }
1664        fn has_transform(&self) -> bool {
1665            true
1666        }
1667    }
1668
1669    #[test]
1670    fn test_fused_transform_error_stops_the_pass() {
1671        let dir = tempfile::tempdir().unwrap();
1672        fs::write(dir.path().join("page.html"), "<p>x</p>").unwrap();
1673
1674        let mut pm = PluginManager::new();
1675        pm.register(FailingTransformPlugin);
1676        assert_eq!(FailingTransformPlugin.name(), "failing-transform");
1677
1678        let mut ctx =
1679            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
1680        ctx.cache_html_files();
1681
1682        let err = pm
1683            .run_fused_transforms(&ctx)
1684            .expect_err("failing transform plugin must surface its error");
1685        let dbg = format!("{err:?}");
1686        assert!(
1687            dbg.contains("transform_html failed"),
1688            "plugin error expected: {dbg}"
1689        );
1690        // The file is untouched.
1691        assert_eq!(
1692            fs::read_to_string(dir.path().join("page.html")).unwrap(),
1693            "<p>x</p>"
1694        );
1695    }
1696}