use crate::cmd::SsgConfig;
use anyhow::{Context, Result};
use std::{
collections::HashMap,
fmt, fs,
path::{Path, PathBuf},
sync::Arc,
};
const CACHE_FILENAME: &str = ".ssg-plugin-cache.json";
#[derive(Debug, Clone, Default)]
pub struct PluginCache {
entries: HashMap<PathBuf, u64>,
}
impl PluginCache {
#[must_use]
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
#[must_use]
pub fn load(site_dir: &Path) -> Self {
let path = site_dir.join(CACHE_FILENAME);
if !path.exists() {
return Self::new();
}
let Ok(content) = fs::read_to_string(&path) else {
return Self::new();
};
let Ok(map) = serde_json::from_str::<HashMap<String, u64>>(&content)
else {
return Self::new();
};
Self {
entries: map
.into_iter()
.map(|(k, v)| (PathBuf::from(k), v))
.collect(),
}
}
pub fn save(&self, site_dir: &Path) -> Result<()> {
let path = site_dir.join(CACHE_FILENAME);
let serialisable: HashMap<String, u64> = self
.entries
.iter()
.map(|(k, v)| (k.to_string_lossy().into_owned(), *v))
.collect();
let json = serde_json::to_string_pretty(&serialisable)
.context("failed to serialise plugin cache")?;
fs::write(&path, json)
.with_context(|| format!("cannot write {}", path.display()))?;
Ok(())
}
pub fn has_changed(&self, path: &Path) -> bool {
let Ok(content) = fs::read(path) else {
return true;
};
let current = Self::hash_bytes(&content);
match self.entries.get(path) {
Some(&cached) => cached != current,
None => true,
}
}
pub fn update(&mut self, path: &Path) {
if let Ok(content) = fs::read(path) {
let hash = Self::hash_bytes(&content);
let _ = self.entries.insert(path.to_path_buf(), hash);
}
}
fn hash_bytes(data: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &byte in data {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
hash
}
}
#[derive(Debug, Clone)]
pub struct PluginContext {
pub content_dir: PathBuf,
pub build_dir: PathBuf,
pub site_dir: PathBuf,
pub template_dir: PathBuf,
pub config: Option<SsgConfig>,
pub cache: Option<PluginCache>,
pub memory_budget: Option<crate::streaming::MemoryBudget>,
pub html_files: Option<Arc<Vec<PathBuf>>>,
pub dep_graph: Option<crate::depgraph::DepGraph>,
}
impl PluginContext {
pub fn cache_html_files(&mut self) {
if self.site_dir.exists() {
let files = crate::walk::walk_files(&self.site_dir, "html")
.unwrap_or_default();
self.html_files = Some(Arc::new(files));
}
}
#[must_use]
pub fn get_html_files(&self) -> Vec<PathBuf> {
if let Some(ref cached) = self.html_files {
cached.as_ref().clone()
} else {
crate::walk::walk_files(&self.site_dir, "html").unwrap_or_default()
}
}
#[must_use]
pub fn new(
content_dir: &Path,
build_dir: &Path,
site_dir: &Path,
template_dir: &Path,
) -> Self {
Self {
content_dir: content_dir.to_path_buf(),
build_dir: build_dir.to_path_buf(),
site_dir: site_dir.to_path_buf(),
template_dir: template_dir.to_path_buf(),
config: None,
cache: None,
memory_budget: None,
html_files: None,
dep_graph: None,
}
}
#[must_use]
pub fn with_config(
content_dir: &Path,
build_dir: &Path,
site_dir: &Path,
template_dir: &Path,
config: SsgConfig,
) -> Self {
Self {
content_dir: content_dir.to_path_buf(),
build_dir: build_dir.to_path_buf(),
site_dir: site_dir.to_path_buf(),
template_dir: template_dir.to_path_buf(),
config: Some(config),
cache: None,
memory_budget: None,
html_files: None,
dep_graph: None,
}
}
}
pub trait Plugin: fmt::Debug + Send + Sync {
fn name(&self) -> &str;
fn before_compile(&self, _ctx: &PluginContext) -> Result<()> {
Ok(())
}
fn after_compile(&self, _ctx: &PluginContext) -> Result<()> {
Ok(())
}
fn transform_html(
&self,
html: &str,
_path: &Path,
_ctx: &PluginContext,
) -> Result<String> {
Ok(html.to_string())
}
fn has_transform(&self) -> bool {
false
}
fn on_serve(&self, _ctx: &PluginContext) -> Result<()> {
Ok(())
}
}
#[derive(Debug, Default)]
pub struct PluginManager {
plugins: Vec<Box<dyn Plugin>>,
}
impl PluginManager {
#[must_use]
pub fn new() -> Self {
Self {
plugins: Vec::new(),
}
}
pub fn register<P: Plugin + 'static>(&mut self, plugin: P) {
self.plugins.push(Box::new(plugin));
}
#[must_use]
pub fn len(&self) -> usize {
self.plugins.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
#[must_use]
pub fn names(&self) -> Vec<&str> {
self.plugins.iter().map(|p| p.name()).collect()
}
pub fn run_before_compile(&self, ctx: &PluginContext) -> Result<()> {
for plugin in &self.plugins {
plugin.before_compile(ctx).map_err(|e| {
anyhow::anyhow!(
"Plugin '{}' failed in before_compile: {}",
plugin.name(),
e
)
})?;
}
Ok(())
}
pub fn run_after_compile(&self, ctx: &PluginContext) -> Result<()> {
for plugin in &self.plugins {
plugin.after_compile(ctx).map_err(|e| {
anyhow::anyhow!(
"Plugin '{}' failed in after_compile: {}",
plugin.name(),
e
)
})?;
}
Ok(())
}
pub fn run_fused_transforms(&self, ctx: &PluginContext) -> Result<()> {
use rayon::prelude::*;
let transform_plugins: Vec<_> =
self.plugins.iter().filter(|p| p.has_transform()).collect();
if transform_plugins.is_empty() {
return Ok(());
}
let html_files = ctx.get_html_files();
let transformed = std::sync::atomic::AtomicUsize::new(0);
html_files.par_iter().try_for_each(|path| -> Result<()> {
let original = fs::read_to_string(path)?;
let mut html = original.clone();
for plugin in &transform_plugins {
html = plugin.transform_html(&html, path, ctx)?;
}
if html != original {
fs::write(path, &html)?;
let _ = transformed
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
Ok(())
})?;
let count = transformed.load(std::sync::atomic::Ordering::Relaxed);
if count > 0 {
log::info!(
"[pipeline] Fused transform: {count} file(s), {} plugin(s)",
transform_plugins.len()
);
}
Ok(())
}
pub fn run_on_serve(&self, ctx: &PluginContext) -> Result<()> {
for plugin in &self.plugins {
plugin.on_serve(ctx).map_err(|e| {
anyhow::anyhow!(
"Plugin '{}' failed in on_serve: {}",
plugin.name(),
e
)
})?;
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug)]
struct CounterPlugin {
name: &'static str,
before: &'static AtomicUsize,
after: &'static AtomicUsize,
serve: &'static AtomicUsize,
}
impl Plugin for CounterPlugin {
fn name(&self) -> &str {
self.name
}
fn before_compile(&self, _ctx: &PluginContext) -> Result<()> {
let _ = self.before.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn after_compile(&self, _ctx: &PluginContext) -> Result<()> {
let _ = self.after.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn on_serve(&self, _ctx: &PluginContext) -> Result<()> {
let _ = self.serve.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
#[derive(Debug)]
struct FailPlugin {
hook: &'static str,
}
impl Plugin for FailPlugin {
fn name(&self) -> &'static str {
"fail-plugin"
}
fn before_compile(&self, _ctx: &PluginContext) -> Result<()> {
if self.hook == "before" {
anyhow::bail!("before_compile failed");
}
Ok(())
}
fn after_compile(&self, _ctx: &PluginContext) -> Result<()> {
if self.hook == "after" {
anyhow::bail!("after_compile failed");
}
Ok(())
}
fn on_serve(&self, _ctx: &PluginContext) -> Result<()> {
if self.hook == "serve" {
anyhow::bail!("on_serve failed");
}
Ok(())
}
}
#[derive(Debug)]
struct NoopPlugin;
impl Plugin for NoopPlugin {
fn name(&self) -> &'static str {
"noop"
}
}
fn test_ctx() -> PluginContext {
PluginContext::new(
Path::new("content"),
Path::new("build"),
Path::new("public"),
Path::new("templates"),
)
}
#[test]
fn test_plugin_manager_new_is_empty() {
let pm = PluginManager::new();
assert!(pm.is_empty());
assert_eq!(pm.len(), 0);
assert!(pm.names().is_empty());
}
#[test]
fn test_plugin_manager_default() {
let pm = PluginManager::default();
assert!(pm.is_empty());
}
#[test]
fn test_register_and_count() {
let mut pm = PluginManager::new();
pm.register(NoopPlugin);
assert_eq!(pm.len(), 1);
assert!(!pm.is_empty());
assert_eq!(pm.names(), vec!["noop"]);
}
#[test]
fn test_multiple_plugins_run_in_order() {
static BEFORE_A: AtomicUsize = AtomicUsize::new(0);
static AFTER_A: AtomicUsize = AtomicUsize::new(0);
static SERVE_A: AtomicUsize = AtomicUsize::new(0);
static BEFORE_B: AtomicUsize = AtomicUsize::new(0);
static AFTER_B: AtomicUsize = AtomicUsize::new(0);
static SERVE_B: AtomicUsize = AtomicUsize::new(0);
let mut pm = PluginManager::new();
pm.register(CounterPlugin {
name: "a",
before: &BEFORE_A,
after: &AFTER_A,
serve: &SERVE_A,
});
pm.register(CounterPlugin {
name: "b",
before: &BEFORE_B,
after: &AFTER_B,
serve: &SERVE_B,
});
let ctx = test_ctx();
pm.run_before_compile(&ctx).unwrap();
pm.run_after_compile(&ctx).unwrap();
pm.run_on_serve(&ctx).unwrap();
assert_eq!(BEFORE_A.load(Ordering::SeqCst), 1);
assert_eq!(BEFORE_B.load(Ordering::SeqCst), 1);
assert_eq!(AFTER_A.load(Ordering::SeqCst), 1);
assert_eq!(AFTER_B.load(Ordering::SeqCst), 1);
assert_eq!(SERVE_A.load(Ordering::SeqCst), 1);
assert_eq!(SERVE_B.load(Ordering::SeqCst), 1);
assert_eq!(pm.names(), vec!["a", "b"]);
}
#[test]
fn test_noop_plugin_all_hooks_succeed() {
let mut pm = PluginManager::new();
pm.register(NoopPlugin);
let ctx = test_ctx();
assert!(pm.run_before_compile(&ctx).is_ok());
assert!(pm.run_after_compile(&ctx).is_ok());
assert!(pm.run_on_serve(&ctx).is_ok());
}
#[test]
fn test_before_compile_error_propagates() {
let mut pm = PluginManager::new();
pm.register(FailPlugin { hook: "before" });
let ctx = test_ctx();
let err = pm.run_before_compile(&ctx).unwrap_err();
assert!(err.to_string().contains("fail-plugin"));
assert!(err.to_string().contains("before_compile"));
}
#[test]
fn test_after_compile_error_propagates() {
let mut pm = PluginManager::new();
pm.register(FailPlugin { hook: "after" });
let ctx = test_ctx();
let err = pm.run_after_compile(&ctx).unwrap_err();
assert!(err.to_string().contains("fail-plugin"));
assert!(err.to_string().contains("after_compile"));
}
#[test]
fn test_on_serve_error_propagates() {
let mut pm = PluginManager::new();
pm.register(FailPlugin { hook: "serve" });
let ctx = test_ctx();
let err = pm.run_on_serve(&ctx).unwrap_err();
assert!(err.to_string().contains("fail-plugin"));
assert!(err.to_string().contains("on_serve"));
}
#[test]
fn test_error_stops_subsequent_plugins() {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let mut pm = PluginManager::new();
pm.register(FailPlugin { hook: "before" });
pm.register(CounterPlugin {
name: "second",
before: &COUNTER,
after: &COUNTER,
serve: &COUNTER,
});
let ctx = test_ctx();
assert!(pm.run_before_compile(&ctx).is_err());
assert_eq!(COUNTER.load(Ordering::SeqCst), 0);
}
#[test]
fn test_empty_manager_hooks_succeed() {
let pm = PluginManager::new();
let ctx = test_ctx();
assert!(pm.run_before_compile(&ctx).is_ok());
assert!(pm.run_after_compile(&ctx).is_ok());
assert!(pm.run_on_serve(&ctx).is_ok());
}
#[test]
fn test_plugin_context_fields() {
let ctx = PluginContext::new(
Path::new("/a"),
Path::new("/b"),
Path::new("/c"),
Path::new("/d"),
);
assert_eq!(ctx.content_dir, PathBuf::from("/a"));
assert_eq!(ctx.build_dir, PathBuf::from("/b"));
assert_eq!(ctx.site_dir, PathBuf::from("/c"));
assert_eq!(ctx.template_dir, PathBuf::from("/d"));
}
#[test]
fn test_plugin_context_clone() {
let ctx = test_ctx();
let cloned = ctx.clone();
assert_eq!(ctx.content_dir, cloned.content_dir);
assert_eq!(ctx.site_dir, cloned.site_dir);
}
#[test]
fn test_plugin_context_debug() {
let ctx = test_ctx();
let debug = format!("{ctx:?}");
assert!(debug.contains("content"));
assert!(debug.contains("build"));
}
#[test]
fn test_plugin_manager_debug() {
let mut pm = PluginManager::new();
pm.register(NoopPlugin);
let debug = format!("{pm:?}");
assert!(debug.contains("NoopPlugin"));
}
#[test]
fn test_cache_new_is_empty() {
let cache = PluginCache::new();
assert!(cache.entries.is_empty());
}
#[test]
fn test_cache_has_changed_on_missing_entry() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("hello.txt");
fs::write(&file, "hello").unwrap();
let cache = PluginCache::new();
assert!(cache.has_changed(&file), "New file should count as changed");
}
#[test]
fn test_cache_has_changed_detects_unchanged() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("hello.txt");
fs::write(&file, "hello").unwrap();
let mut cache = PluginCache::new();
cache.update(&file);
assert!(
!cache.has_changed(&file),
"File should not be changed after update"
);
}
#[test]
fn test_cache_has_changed_detects_modification() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("hello.txt");
fs::write(&file, "hello").unwrap();
let mut cache = PluginCache::new();
cache.update(&file);
fs::write(&file, "world").unwrap();
assert!(
cache.has_changed(&file),
"Modified file should be detected as changed"
);
}
#[test]
fn test_cache_persistence_save_load() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("data.txt");
fs::write(&file, "content").unwrap();
let mut cache = PluginCache::new();
cache.update(&file);
cache.save(tmp.path()).unwrap();
let cache_path = tmp.path().join(CACHE_FILENAME);
assert!(cache_path.exists(), "Cache file should be persisted");
let loaded = PluginCache::load(tmp.path());
assert!(
!loaded.has_changed(&file),
"Loaded cache should still recognise unchanged file"
);
}
#[test]
fn test_cache_load_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let cache = PluginCache::load(tmp.path());
assert!(cache.entries.is_empty());
}
#[test]
fn test_cache_has_changed_nonexistent_file() {
let cache = PluginCache::new();
assert!(
cache.has_changed(Path::new("/nonexistent/file.txt")),
"Nonexistent file should count as changed"
);
}
#[test]
fn test_cache_save_load_round_trip_with_multiple_files() {
let tmp = tempfile::tempdir().unwrap();
let f1 = tmp.path().join("one.txt");
let f2 = tmp.path().join("two.txt");
fs::write(&f1, "alpha").unwrap();
fs::write(&f2, "beta").unwrap();
let mut cache = PluginCache::new();
cache.update(&f1);
cache.update(&f2);
cache.save(tmp.path()).unwrap();
let loaded = PluginCache::load(tmp.path());
assert!(!loaded.has_changed(&f1));
assert!(!loaded.has_changed(&f2));
}
#[test]
fn test_cache_empty_save_load() {
let tmp = tempfile::tempdir().unwrap();
let cache = PluginCache::new();
cache.save(tmp.path()).unwrap();
let loaded = PluginCache::load(tmp.path());
assert!(loaded.entries.is_empty());
}
#[test]
fn test_cache_hash_bytes_determinism() {
let data = b"hello world";
let h1 = PluginCache::hash_bytes(data);
let h2 = PluginCache::hash_bytes(data);
assert_eq!(h1, h2, "same input must produce same hash");
}
#[test]
fn test_cache_hash_bytes_different_inputs() {
let h1 = PluginCache::hash_bytes(b"aaa");
let h2 = PluginCache::hash_bytes(b"bbb");
assert_ne!(h1, h2, "different inputs should produce different hashes");
}
#[test]
fn test_cache_hash_bytes_empty() {
let h = PluginCache::hash_bytes(b"");
assert_eq!(h, 0xcbf2_9ce4_8422_2325);
}
#[test]
fn test_cache_has_changed_after_file_modification() {
let tmp = tempfile::tempdir().unwrap();
let f = tmp.path().join("data.txt");
fs::write(&f, "version1").unwrap();
let mut cache = PluginCache::new();
cache.update(&f);
assert!(!cache.has_changed(&f));
fs::write(&f, "version2").unwrap();
assert!(cache.has_changed(&f));
cache.update(&f);
assert!(!cache.has_changed(&f));
}
#[test]
fn test_cache_load_corrupt_json() {
let tmp = tempfile::tempdir().unwrap();
let cache_path = tmp.path().join(CACHE_FILENAME);
fs::write(&cache_path, "this is not json").unwrap();
let loaded = PluginCache::load(tmp.path());
assert!(
loaded.entries.is_empty(),
"corrupt JSON should yield empty cache"
);
}
#[test]
fn test_cache_update_nonexistent_file_is_noop() {
let mut cache = PluginCache::new();
cache.update(Path::new("/nonexistent/file.txt"));
assert!(cache.entries.is_empty());
}
#[test]
fn test_cache_default_is_empty() {
let cache = PluginCache::default();
assert!(cache.entries.is_empty());
}
#[test]
fn test_cache_clone() {
let tmp = tempfile::tempdir().unwrap();
let f = tmp.path().join("x.txt");
fs::write(&f, "x").unwrap();
let mut cache = PluginCache::new();
cache.update(&f);
let cloned = cache.clone();
assert!(!cloned.has_changed(&f));
}
#[test]
fn test_plugin_context_with_config() {
let config = SsgConfig::builder()
.site_name("test".to_string())
.base_url("https://example.com".to_string())
.build()
.expect("config");
let ctx = PluginContext::with_config(
Path::new("c"),
Path::new("b"),
Path::new("s"),
Path::new("t"),
config,
);
assert!(ctx.config.is_some());
assert_eq!(ctx.config.unwrap().site_name, "test");
}
#[test]
fn test_fail_plugin_non_matching_hooks_succeed() {
let ctx = test_ctx();
let p = FailPlugin { hook: "before" };
assert!(p.after_compile(&ctx).is_ok());
assert!(p.on_serve(&ctx).is_ok());
let p = FailPlugin { hook: "after" };
assert!(p.before_compile(&ctx).is_ok());
assert!(p.on_serve(&ctx).is_ok());
let p = FailPlugin { hook: "serve" };
assert!(p.before_compile(&ctx).is_ok());
assert!(p.after_compile(&ctx).is_ok());
}
}