Skip to main content

ssg/
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) -> Result<()> {
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 anyhow::{Context, Result};
42use std::{
43    collections::HashMap,
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: HashMap<PathBuf, u64>,
63}
64
65impl PluginCache {
66    /// Creates an empty cache.
67    #[must_use]
68    pub fn new() -> Self {
69        Self {
70            entries: HashMap::new(),
71        }
72    }
73
74    /// Loads a cache from `site_dir/.ssg-plugin-cache.json`.
75    ///
76    /// Returns an empty cache if the file does not exist or cannot be
77    /// parsed.
78    #[must_use]
79    pub fn load(site_dir: &Path) -> Self {
80        let path = site_dir.join(CACHE_FILENAME);
81        if !path.exists() {
82            return Self::new();
83        }
84        let Ok(content) = fs::read_to_string(&path) else {
85            return Self::new();
86        };
87        let Ok(map) = serde_json::from_str::<HashMap<String, u64>>(&content)
88        else {
89            return Self::new();
90        };
91        Self {
92            entries: map
93                .into_iter()
94                .map(|(k, v)| (PathBuf::from(k), v))
95                .collect(),
96        }
97    }
98
99    /// Persists the cache to `site_dir/.ssg-plugin-cache.json`.
100    pub fn save(&self, site_dir: &Path) -> Result<()> {
101        let path = site_dir.join(CACHE_FILENAME);
102        let serialisable: HashMap<String, u64> = self
103            .entries
104            .iter()
105            .map(|(k, v)| (k.to_string_lossy().into_owned(), *v))
106            .collect();
107        let json = serde_json::to_string_pretty(&serialisable)
108            .context("failed to serialise plugin cache")?;
109        fs::write(&path, json)
110            .with_context(|| format!("cannot write {}", path.display()))?;
111        Ok(())
112    }
113
114    /// Returns `true` if the file at `path` has changed since the last
115    /// time it was recorded, or if it has never been recorded.
116    pub fn has_changed(&self, path: &Path) -> bool {
117        let Ok(content) = fs::read(path) else {
118            return true;
119        };
120        let current = Self::hash_bytes(&content);
121        match self.entries.get(path) {
122            Some(&cached) => cached != current,
123            None => true,
124        }
125    }
126
127    /// Records the current content hash for `path`.
128    pub fn update(&mut self, path: &Path) {
129        if let Ok(content) = fs::read(path) {
130            let hash = Self::hash_bytes(&content);
131            let _ = self.entries.insert(path.to_path_buf(), hash);
132        }
133    }
134
135    /// Simple FNV-1a 64-bit hash of a byte slice.
136    fn hash_bytes(data: &[u8]) -> u64 {
137        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
138        for &byte in data {
139            hash ^= u64::from(byte);
140            hash = hash.wrapping_mul(0x0100_0000_01b3);
141        }
142        hash
143    }
144}
145
146/// Context passed to plugin hooks with paths and configuration.
147#[derive(Debug, Clone)]
148pub struct PluginContext {
149    /// The content source directory.
150    pub content_dir: PathBuf,
151    /// The build/output directory.
152    pub build_dir: PathBuf,
153    /// The final site directory.
154    pub site_dir: PathBuf,
155    /// The template directory.
156    pub template_dir: PathBuf,
157    /// Site configuration (`base_url`, `site_name`, language, etc.).
158    pub config: Option<SsgConfig>,
159    /// Content-addressed plugin cache for incremental builds.
160    pub cache: Option<PluginCache>,
161    /// Memory budget for streaming compilation.
162    pub memory_budget: Option<crate::streaming::MemoryBudget>,
163    /// Cached list of HTML files in `site_dir`, walked once and shared
164    /// across all plugins to avoid redundant filesystem traversals.
165    pub html_files: Option<Arc<Vec<PathBuf>>>,
166    /// Page dependency graph for incremental rebuilds.
167    pub dep_graph: Option<crate::depgraph::DepGraph>,
168}
169
170impl PluginContext {
171    /// Populates the cached HTML file list by walking `site_dir` once.
172    /// Call this before running `after_compile` plugins to eliminate
173    /// redundant directory scans (8+ plugins read the same file list).
174    pub fn cache_html_files(&mut self) {
175        if self.site_dir.exists() {
176            let files = crate::walk::walk_files(&self.site_dir, "html")
177                .unwrap_or_default();
178            self.html_files = Some(Arc::new(files));
179        }
180    }
181
182    /// Returns the cached HTML file list, or walks the directory if
183    /// the cache hasn't been populated.
184    #[must_use]
185    pub fn get_html_files(&self) -> Vec<PathBuf> {
186        if let Some(ref cached) = self.html_files {
187            cached.as_ref().clone()
188        } else {
189            crate::walk::walk_files(&self.site_dir, "html").unwrap_or_default()
190        }
191    }
192
193    /// Creates a new plugin context from directory paths.
194    #[must_use]
195    pub fn new(
196        content_dir: &Path,
197        build_dir: &Path,
198        site_dir: &Path,
199        template_dir: &Path,
200    ) -> Self {
201        Self {
202            content_dir: content_dir.to_path_buf(),
203            build_dir: build_dir.to_path_buf(),
204            site_dir: site_dir.to_path_buf(),
205            template_dir: template_dir.to_path_buf(),
206            config: None,
207            cache: None,
208            memory_budget: None,
209            html_files: None,
210            dep_graph: None,
211        }
212    }
213
214    /// Creates a new plugin context with site configuration.
215    #[must_use]
216    pub fn with_config(
217        content_dir: &Path,
218        build_dir: &Path,
219        site_dir: &Path,
220        template_dir: &Path,
221        config: SsgConfig,
222    ) -> Self {
223        Self {
224            content_dir: content_dir.to_path_buf(),
225            build_dir: build_dir.to_path_buf(),
226            site_dir: site_dir.to_path_buf(),
227            template_dir: template_dir.to_path_buf(),
228            config: Some(config),
229            cache: None,
230            memory_budget: None,
231            html_files: None,
232            dep_graph: None,
233        }
234    }
235}
236
237/// Trait for SSG plugins.
238///
239/// Implement this trait to create a plugin that hooks into the site
240/// generation lifecycle. All hooks have default no-op implementations,
241/// so you only need to override the ones you care about.
242///
243/// # Stability contract
244///
245/// This trait is part of the SSG public API. The stability commitment
246/// for the `1.0` line is:
247///
248/// 1. **All current hook signatures are frozen.** Once `1.0` ships, no
249///    parameter, return type, or trait bound on an existing method
250///    will change without a major version bump.
251/// 2. **New hooks land with a default `Ok(())` implementation.**
252///    Adding a new hook is therefore non-breaking — existing
253///    `impl Plugin for …` blocks continue to compile.
254/// 3. **`PluginContext` is `#[non_exhaustive]`.** New fields (e.g.
255///    additional caches, link graphs, image metadata) can be added
256///    without breaking downstream construction sites — those are
257///    constructed inside SSG, not by plugin authors.
258/// 4. **Removing a hook requires a major bump.** Hook removal is rare
259///    and always preceded by a deprecation cycle of at least one
260///    minor release with `#[deprecated]` and a migration note in the
261///    CHANGELOG.
262///
263/// See [API stability audit](../../docs/architecture/api-stability-audit.md)
264/// for the full Tier-A inventory.
265pub trait Plugin: fmt::Debug + Send + Sync {
266    /// Returns the unique name of this plugin.
267    fn name(&self) -> &str;
268
269    /// Called before site compilation begins.
270    ///
271    /// Use this hook to preprocess content files, inject metadata,
272    /// or validate source directories.
273    fn before_compile(&self, _ctx: &PluginContext) -> Result<()> {
274        Ok(())
275    }
276
277    /// Called after site compilation completes.
278    ///
279    /// Use this hook to post-process generated HTML, optimize assets,
280    /// generate sitemaps, or perform any output transformation.
281    fn after_compile(&self, _ctx: &PluginContext) -> Result<()> {
282        Ok(())
283    }
284
285    /// Per-file HTML transform hook — called once per HTML file during
286    /// the fused transform pass.
287    ///
288    /// Receives the current HTML content and returns the (possibly modified)
289    /// HTML. The default implementation returns the input unchanged.
290    ///
291    /// Plugins that implement this hook avoid redundant file I/O — the
292    /// pipeline reads each HTML file once, pipes it through all plugins'
293    /// `transform_html` hooks, then writes the result once.
294    fn transform_html(
295        &self,
296        html: &str,
297        _path: &Path,
298        _ctx: &PluginContext,
299    ) -> Result<String> {
300        Ok(html.to_string())
301    }
302
303    /// Returns `true` if this plugin implements `transform_html`.
304    /// Override to `true` to opt in to the fused transform pass.
305    fn has_transform(&self) -> bool {
306        false
307    }
308
309    /// Called before the development server starts serving files.
310    ///
311    /// Use this hook to inject dev-mode scripts, set up live-reload,
312    /// or modify the serve directory.
313    fn on_serve(&self, _ctx: &PluginContext) -> Result<()> {
314        Ok(())
315    }
316}
317
318/// Manages registered plugins and executes lifecycle hooks.
319///
320/// # Example
321///
322/// ```rust
323/// use ssg::plugin::{PluginManager, PluginContext, Plugin};
324/// use anyhow::Result;
325/// use std::path::Path;
326///
327/// #[derive(Debug)]
328/// struct LogPlugin;
329///
330/// impl Plugin for LogPlugin {
331///     fn name(&self) -> &str { "logger" }
332///     fn before_compile(&self, ctx: &PluginContext) -> Result<()> {
333///         println!("Compiling from {:?}", ctx.content_dir);
334///         Ok(())
335///     }
336/// }
337///
338/// let mut pm = PluginManager::new();
339/// pm.register(LogPlugin);
340/// assert_eq!(pm.len(), 1);
341///
342/// let ctx = PluginContext::new(
343///     Path::new("content"),
344///     Path::new("build"),
345///     Path::new("public"),
346///     Path::new("templates"),
347/// );
348/// pm.run_before_compile(&ctx).unwrap();
349/// ```
350#[derive(Debug, Default)]
351pub struct PluginManager {
352    plugins: Vec<Box<dyn Plugin>>,
353}
354
355impl PluginManager {
356    /// Creates a new empty plugin manager.
357    #[must_use]
358    pub fn new() -> Self {
359        Self {
360            plugins: Vec::new(),
361        }
362    }
363
364    /// Registers a plugin.
365    ///
366    /// Plugins run in the order they are registered.
367    pub fn register<P: Plugin + 'static>(&mut self, plugin: P) {
368        self.plugins.push(Box::new(plugin));
369    }
370
371    /// Returns the number of registered plugins.
372    #[must_use]
373    pub fn len(&self) -> usize {
374        self.plugins.len()
375    }
376
377    /// Returns `true` if no plugins are registered.
378    #[must_use]
379    pub fn is_empty(&self) -> bool {
380        self.plugins.is_empty()
381    }
382
383    /// Returns the names of all registered plugins.
384    #[must_use]
385    pub fn names(&self) -> Vec<&str> {
386        self.plugins.iter().map(|p| p.name()).collect()
387    }
388
389    /// Runs the `before_compile` hook on all registered plugins.
390    ///
391    /// Plugins execute in registration order. If any plugin returns
392    /// an error, execution stops and the error is propagated.
393    pub fn run_before_compile(&self, ctx: &PluginContext) -> Result<()> {
394        for plugin in &self.plugins {
395            plugin.before_compile(ctx).map_err(|e| {
396                anyhow::anyhow!(
397                    "Plugin '{}' failed in before_compile: {}",
398                    plugin.name(),
399                    e
400                )
401            })?;
402        }
403        Ok(())
404    }
405
406    /// Runs the `after_compile` hook on all registered plugins.
407    ///
408    /// Plugins execute in registration order. If any plugin returns
409    /// an error, execution stops and the error is propagated.
410    pub fn run_after_compile(&self, ctx: &PluginContext) -> Result<()> {
411        for plugin in &self.plugins {
412            plugin.after_compile(ctx).map_err(|e| {
413                anyhow::anyhow!(
414                    "Plugin '{}' failed in after_compile: {}",
415                    plugin.name(),
416                    e
417                )
418            })?;
419        }
420        Ok(())
421    }
422
423    /// Runs the fused HTML transform pass: reads each HTML file once,
424    /// pipes through all plugins with `has_transform() == true`, writes once.
425    ///
426    /// This eliminates N separate read/write cycles (where N = number of
427    /// transform plugins) per HTML file.
428    pub fn run_fused_transforms(&self, ctx: &PluginContext) -> Result<()> {
429        use rayon::prelude::*;
430
431        let transform_plugins: Vec<_> =
432            self.plugins.iter().filter(|p| p.has_transform()).collect();
433
434        if transform_plugins.is_empty() {
435            return Ok(());
436        }
437
438        let html_files = ctx.get_html_files();
439        let transformed = std::sync::atomic::AtomicUsize::new(0);
440
441        html_files.par_iter().try_for_each(|path| -> Result<()> {
442            let original = fs::read_to_string(path)?;
443            let mut html = original.clone();
444
445            for plugin in &transform_plugins {
446                html = plugin.transform_html(&html, path, ctx)?;
447            }
448
449            if html != original {
450                fs::write(path, &html)?;
451                let _ = transformed
452                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
453            }
454            Ok(())
455        })?;
456
457        let count = transformed.load(std::sync::atomic::Ordering::Relaxed);
458        if count > 0 {
459            log::info!(
460                "[pipeline] Fused transform: {count} file(s), {} plugin(s)",
461                transform_plugins.len()
462            );
463        }
464        Ok(())
465    }
466
467    /// Runs the `on_serve` hook on all registered plugins.
468    ///
469    /// Plugins execute in registration order. If any plugin returns
470    /// an error, execution stops and the error is propagated.
471    pub fn run_on_serve(&self, ctx: &PluginContext) -> Result<()> {
472        for plugin in &self.plugins {
473            plugin.on_serve(ctx).map_err(|e| {
474                anyhow::anyhow!(
475                    "Plugin '{}' failed in on_serve: {}",
476                    plugin.name(),
477                    e
478                )
479            })?;
480        }
481        Ok(())
482    }
483}
484
485#[cfg(test)]
486#[allow(clippy::unwrap_used, clippy::expect_used)]
487mod tests {
488    use super::*;
489    use std::sync::atomic::{AtomicUsize, Ordering};
490
491    #[derive(Debug)]
492    struct CounterPlugin {
493        name: &'static str,
494        before: &'static AtomicUsize,
495        after: &'static AtomicUsize,
496        serve: &'static AtomicUsize,
497    }
498
499    impl Plugin for CounterPlugin {
500        fn name(&self) -> &str {
501            self.name
502        }
503        fn before_compile(&self, _ctx: &PluginContext) -> Result<()> {
504            let _ = self.before.fetch_add(1, Ordering::SeqCst);
505            Ok(())
506        }
507        fn after_compile(&self, _ctx: &PluginContext) -> Result<()> {
508            let _ = self.after.fetch_add(1, Ordering::SeqCst);
509            Ok(())
510        }
511        fn on_serve(&self, _ctx: &PluginContext) -> Result<()> {
512            let _ = self.serve.fetch_add(1, Ordering::SeqCst);
513            Ok(())
514        }
515    }
516
517    #[derive(Debug)]
518    struct FailPlugin {
519        hook: &'static str,
520    }
521
522    impl Plugin for FailPlugin {
523        fn name(&self) -> &'static str {
524            "fail-plugin"
525        }
526        fn before_compile(&self, _ctx: &PluginContext) -> Result<()> {
527            if self.hook == "before" {
528                anyhow::bail!("before_compile failed");
529            }
530            Ok(())
531        }
532        fn after_compile(&self, _ctx: &PluginContext) -> Result<()> {
533            if self.hook == "after" {
534                anyhow::bail!("after_compile failed");
535            }
536            Ok(())
537        }
538        fn on_serve(&self, _ctx: &PluginContext) -> Result<()> {
539            if self.hook == "serve" {
540                anyhow::bail!("on_serve failed");
541            }
542            Ok(())
543        }
544    }
545
546    #[derive(Debug)]
547    struct NoopPlugin;
548
549    impl Plugin for NoopPlugin {
550        fn name(&self) -> &'static str {
551            "noop"
552        }
553    }
554
555    fn test_ctx() -> PluginContext {
556        PluginContext::new(
557            Path::new("content"),
558            Path::new("build"),
559            Path::new("public"),
560            Path::new("templates"),
561        )
562    }
563
564    #[test]
565    fn test_plugin_manager_new_is_empty() {
566        let pm = PluginManager::new();
567        assert!(pm.is_empty());
568        assert_eq!(pm.len(), 0);
569        assert!(pm.names().is_empty());
570    }
571
572    #[test]
573    fn test_plugin_manager_default() {
574        let pm = PluginManager::default();
575        assert!(pm.is_empty());
576    }
577
578    #[test]
579    fn test_register_and_count() {
580        let mut pm = PluginManager::new();
581        pm.register(NoopPlugin);
582        assert_eq!(pm.len(), 1);
583        assert!(!pm.is_empty());
584        assert_eq!(pm.names(), vec!["noop"]);
585    }
586
587    #[test]
588    fn test_multiple_plugins_run_in_order() {
589        static BEFORE_A: AtomicUsize = AtomicUsize::new(0);
590        static AFTER_A: AtomicUsize = AtomicUsize::new(0);
591        static SERVE_A: AtomicUsize = AtomicUsize::new(0);
592        static BEFORE_B: AtomicUsize = AtomicUsize::new(0);
593        static AFTER_B: AtomicUsize = AtomicUsize::new(0);
594        static SERVE_B: AtomicUsize = AtomicUsize::new(0);
595
596        let mut pm = PluginManager::new();
597        pm.register(CounterPlugin {
598            name: "a",
599            before: &BEFORE_A,
600            after: &AFTER_A,
601            serve: &SERVE_A,
602        });
603        pm.register(CounterPlugin {
604            name: "b",
605            before: &BEFORE_B,
606            after: &AFTER_B,
607            serve: &SERVE_B,
608        });
609
610        let ctx = test_ctx();
611        pm.run_before_compile(&ctx).unwrap();
612        pm.run_after_compile(&ctx).unwrap();
613        pm.run_on_serve(&ctx).unwrap();
614
615        assert_eq!(BEFORE_A.load(Ordering::SeqCst), 1);
616        assert_eq!(BEFORE_B.load(Ordering::SeqCst), 1);
617        assert_eq!(AFTER_A.load(Ordering::SeqCst), 1);
618        assert_eq!(AFTER_B.load(Ordering::SeqCst), 1);
619        assert_eq!(SERVE_A.load(Ordering::SeqCst), 1);
620        assert_eq!(SERVE_B.load(Ordering::SeqCst), 1);
621        assert_eq!(pm.names(), vec!["a", "b"]);
622    }
623
624    #[test]
625    fn test_noop_plugin_all_hooks_succeed() {
626        let mut pm = PluginManager::new();
627        pm.register(NoopPlugin);
628        let ctx = test_ctx();
629        assert!(pm.run_before_compile(&ctx).is_ok());
630        assert!(pm.run_after_compile(&ctx).is_ok());
631        assert!(pm.run_on_serve(&ctx).is_ok());
632    }
633
634    #[test]
635    fn test_before_compile_error_propagates() {
636        let mut pm = PluginManager::new();
637        pm.register(FailPlugin { hook: "before" });
638        let ctx = test_ctx();
639        let err = pm.run_before_compile(&ctx).unwrap_err();
640        assert!(err.to_string().contains("fail-plugin"));
641        assert!(err.to_string().contains("before_compile"));
642    }
643
644    #[test]
645    fn test_after_compile_error_propagates() {
646        let mut pm = PluginManager::new();
647        pm.register(FailPlugin { hook: "after" });
648        let ctx = test_ctx();
649        let err = pm.run_after_compile(&ctx).unwrap_err();
650        assert!(err.to_string().contains("fail-plugin"));
651        assert!(err.to_string().contains("after_compile"));
652    }
653
654    #[test]
655    fn test_on_serve_error_propagates() {
656        let mut pm = PluginManager::new();
657        pm.register(FailPlugin { hook: "serve" });
658        let ctx = test_ctx();
659        let err = pm.run_on_serve(&ctx).unwrap_err();
660        assert!(err.to_string().contains("fail-plugin"));
661        assert!(err.to_string().contains("on_serve"));
662    }
663
664    #[test]
665    fn test_error_stops_subsequent_plugins() {
666        static COUNTER: AtomicUsize = AtomicUsize::new(0);
667
668        let mut pm = PluginManager::new();
669        pm.register(FailPlugin { hook: "before" });
670        pm.register(CounterPlugin {
671            name: "second",
672            before: &COUNTER,
673            after: &COUNTER,
674            serve: &COUNTER,
675        });
676
677        let ctx = test_ctx();
678        assert!(pm.run_before_compile(&ctx).is_err());
679        // Second plugin should not have run
680        assert_eq!(COUNTER.load(Ordering::SeqCst), 0);
681    }
682
683    #[test]
684    fn test_empty_manager_hooks_succeed() {
685        let pm = PluginManager::new();
686        let ctx = test_ctx();
687        assert!(pm.run_before_compile(&ctx).is_ok());
688        assert!(pm.run_after_compile(&ctx).is_ok());
689        assert!(pm.run_on_serve(&ctx).is_ok());
690    }
691
692    #[test]
693    fn test_plugin_context_fields() {
694        let ctx = PluginContext::new(
695            Path::new("/a"),
696            Path::new("/b"),
697            Path::new("/c"),
698            Path::new("/d"),
699        );
700        assert_eq!(ctx.content_dir, PathBuf::from("/a"));
701        assert_eq!(ctx.build_dir, PathBuf::from("/b"));
702        assert_eq!(ctx.site_dir, PathBuf::from("/c"));
703        assert_eq!(ctx.template_dir, PathBuf::from("/d"));
704    }
705
706    #[test]
707    fn test_plugin_context_clone() {
708        let ctx = test_ctx();
709        let cloned = ctx.clone();
710        assert_eq!(ctx.content_dir, cloned.content_dir);
711        assert_eq!(ctx.site_dir, cloned.site_dir);
712    }
713
714    #[test]
715    fn test_plugin_context_debug() {
716        let ctx = test_ctx();
717        let debug = format!("{ctx:?}");
718        assert!(debug.contains("content"));
719        assert!(debug.contains("build"));
720    }
721
722    #[test]
723    fn test_plugin_manager_debug() {
724        let mut pm = PluginManager::new();
725        pm.register(NoopPlugin);
726        let debug = format!("{pm:?}");
727        assert!(debug.contains("NoopPlugin"));
728    }
729
730    // -----------------------------------------------------------------
731    // PluginCache tests
732    // -----------------------------------------------------------------
733
734    #[test]
735    fn test_cache_new_is_empty() {
736        let cache = PluginCache::new();
737        assert!(cache.entries.is_empty());
738    }
739
740    #[test]
741    fn test_cache_has_changed_on_missing_entry() {
742        let tmp = tempfile::tempdir().unwrap();
743        let file = tmp.path().join("hello.txt");
744        fs::write(&file, "hello").unwrap();
745
746        let cache = PluginCache::new();
747        assert!(cache.has_changed(&file), "New file should count as changed");
748    }
749
750    #[test]
751    fn test_cache_has_changed_detects_unchanged() {
752        let tmp = tempfile::tempdir().unwrap();
753        let file = tmp.path().join("hello.txt");
754        fs::write(&file, "hello").unwrap();
755
756        let mut cache = PluginCache::new();
757        cache.update(&file);
758        assert!(
759            !cache.has_changed(&file),
760            "File should not be changed after update"
761        );
762    }
763
764    #[test]
765    fn test_cache_has_changed_detects_modification() {
766        let tmp = tempfile::tempdir().unwrap();
767        let file = tmp.path().join("hello.txt");
768        fs::write(&file, "hello").unwrap();
769
770        let mut cache = PluginCache::new();
771        cache.update(&file);
772
773        // Modify the file
774        fs::write(&file, "world").unwrap();
775        assert!(
776            cache.has_changed(&file),
777            "Modified file should be detected as changed"
778        );
779    }
780
781    #[test]
782    fn test_cache_persistence_save_load() {
783        let tmp = tempfile::tempdir().unwrap();
784        let file = tmp.path().join("data.txt");
785        fs::write(&file, "content").unwrap();
786
787        let mut cache = PluginCache::new();
788        cache.update(&file);
789        cache.save(tmp.path()).unwrap();
790
791        // Verify the cache file exists
792        let cache_path = tmp.path().join(CACHE_FILENAME);
793        assert!(cache_path.exists(), "Cache file should be persisted");
794
795        // Load it back
796        let loaded = PluginCache::load(tmp.path());
797        assert!(
798            !loaded.has_changed(&file),
799            "Loaded cache should still recognise unchanged file"
800        );
801    }
802
803    #[test]
804    fn test_cache_load_missing_file() {
805        let tmp = tempfile::tempdir().unwrap();
806        let cache = PluginCache::load(tmp.path());
807        assert!(cache.entries.is_empty());
808    }
809
810    #[test]
811    fn test_cache_has_changed_nonexistent_file() {
812        let cache = PluginCache::new();
813        assert!(
814            cache.has_changed(Path::new("/nonexistent/file.txt")),
815            "Nonexistent file should count as changed"
816        );
817    }
818
819    // -----------------------------------------------------------------
820    // PluginCache: save/load round-trip, hash determinism, empty cache
821    // -----------------------------------------------------------------
822
823    #[test]
824    fn test_cache_save_load_round_trip_with_multiple_files() {
825        let tmp = tempfile::tempdir().unwrap();
826        let f1 = tmp.path().join("one.txt");
827        let f2 = tmp.path().join("two.txt");
828        fs::write(&f1, "alpha").unwrap();
829        fs::write(&f2, "beta").unwrap();
830
831        let mut cache = PluginCache::new();
832        cache.update(&f1);
833        cache.update(&f2);
834        cache.save(tmp.path()).unwrap();
835
836        let loaded = PluginCache::load(tmp.path());
837        assert!(!loaded.has_changed(&f1));
838        assert!(!loaded.has_changed(&f2));
839    }
840
841    #[test]
842    fn test_cache_empty_save_load() {
843        let tmp = tempfile::tempdir().unwrap();
844        let cache = PluginCache::new();
845        cache.save(tmp.path()).unwrap();
846
847        let loaded = PluginCache::load(tmp.path());
848        assert!(loaded.entries.is_empty());
849    }
850
851    #[test]
852    fn test_cache_hash_bytes_determinism() {
853        let data = b"hello world";
854        let h1 = PluginCache::hash_bytes(data);
855        let h2 = PluginCache::hash_bytes(data);
856        assert_eq!(h1, h2, "same input must produce same hash");
857    }
858
859    #[test]
860    fn test_cache_hash_bytes_different_inputs() {
861        let h1 = PluginCache::hash_bytes(b"aaa");
862        let h2 = PluginCache::hash_bytes(b"bbb");
863        assert_ne!(h1, h2, "different inputs should produce different hashes");
864    }
865
866    #[test]
867    fn test_cache_hash_bytes_empty() {
868        // Empty input should return the FNV offset basis
869        let h = PluginCache::hash_bytes(b"");
870        assert_eq!(h, 0xcbf2_9ce4_8422_2325);
871    }
872
873    #[test]
874    fn test_cache_has_changed_after_file_modification() {
875        let tmp = tempfile::tempdir().unwrap();
876        let f = tmp.path().join("data.txt");
877        fs::write(&f, "version1").unwrap();
878
879        let mut cache = PluginCache::new();
880        cache.update(&f);
881        assert!(!cache.has_changed(&f));
882
883        // Modify file content
884        fs::write(&f, "version2").unwrap();
885        assert!(cache.has_changed(&f));
886
887        // Update cache, should no longer be changed
888        cache.update(&f);
889        assert!(!cache.has_changed(&f));
890    }
891
892    #[test]
893    fn test_cache_load_corrupt_json() {
894        let tmp = tempfile::tempdir().unwrap();
895        let cache_path = tmp.path().join(CACHE_FILENAME);
896        fs::write(&cache_path, "this is not json").unwrap();
897
898        let loaded = PluginCache::load(tmp.path());
899        assert!(
900            loaded.entries.is_empty(),
901            "corrupt JSON should yield empty cache"
902        );
903    }
904
905    #[test]
906    fn test_cache_update_nonexistent_file_is_noop() {
907        let mut cache = PluginCache::new();
908        cache.update(Path::new("/nonexistent/file.txt"));
909        assert!(cache.entries.is_empty());
910    }
911
912    #[test]
913    fn test_cache_default_is_empty() {
914        let cache = PluginCache::default();
915        assert!(cache.entries.is_empty());
916    }
917
918    #[test]
919    fn test_cache_clone() {
920        let tmp = tempfile::tempdir().unwrap();
921        let f = tmp.path().join("x.txt");
922        fs::write(&f, "x").unwrap();
923
924        let mut cache = PluginCache::new();
925        cache.update(&f);
926
927        let cloned = cache.clone();
928        assert!(!cloned.has_changed(&f));
929    }
930
931    #[test]
932    fn test_plugin_context_with_config() {
933        let config = SsgConfig::builder()
934            .site_name("test".to_string())
935            .base_url("https://example.com".to_string())
936            .build()
937            .expect("config");
938        let ctx = PluginContext::with_config(
939            Path::new("c"),
940            Path::new("b"),
941            Path::new("s"),
942            Path::new("t"),
943            config,
944        );
945        assert!(ctx.config.is_some());
946        assert_eq!(ctx.config.unwrap().site_name, "test");
947    }
948
949    #[test]
950    fn test_fail_plugin_non_matching_hooks_succeed() {
951        let ctx = test_ctx();
952
953        // FailPlugin("before") should succeed on after_compile and on_serve
954        let p = FailPlugin { hook: "before" };
955        assert!(p.after_compile(&ctx).is_ok());
956        assert!(p.on_serve(&ctx).is_ok());
957
958        // FailPlugin("after") should succeed on before_compile and on_serve
959        let p = FailPlugin { hook: "after" };
960        assert!(p.before_compile(&ctx).is_ok());
961        assert!(p.on_serve(&ctx).is_ok());
962
963        // FailPlugin("serve") should succeed on before_compile and after_compile
964        let p = FailPlugin { hook: "serve" };
965        assert!(p.before_compile(&ctx).is_ok());
966        assert!(p.after_compile(&ctx).is_ok());
967    }
968}