use crate::cmd::SsgConfig;
use crate::error::{PathErrorExt, SsgError};
use std::{
collections::BTreeMap,
fmt, fs,
path::{Path, PathBuf},
sync::Arc,
};
const CACHE_FILENAME: &str = ".ssg-plugin-cache.json";
#[derive(Debug, Clone, Default)]
pub struct PluginCache {
entries: BTreeMap<PathBuf, u64>,
}
impl PluginCache {
#[must_use]
pub const fn new() -> Self {
Self {
entries: BTreeMap::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::<BTreeMap<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<(), SsgError> {
let path = site_dir.join(CACHE_FILENAME);
let serialisable: BTreeMap<String, u64> = self
.entries
.iter()
.map(|(k, v)| (k.to_string_lossy().into_owned(), *v))
.collect();
let json =
serde_json::to_string_pretty(&serialisable).map_err(|e| {
SsgError::Io {
path: path.clone(),
source: std::io::Error::other(e),
}
})?;
fs::write(&path, json).with_path(&path)?;
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>,
pub dry_run: bool,
}
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,
dry_run: false,
}
}
#[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,
dry_run: false,
}
}
#[must_use]
pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
}
pub trait Plugin: fmt::Debug + Send + Sync {
fn name(&self) -> &str;
fn before_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
Ok(())
}
fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
Ok(())
}
fn transform_html(
&self,
html: &str,
_path: &Path,
_ctx: &PluginContext,
) -> Result<String, SsgError> {
Ok(html.to_string())
}
fn has_transform(&self) -> bool {
false
}
fn needs_all_files(&self) -> bool {
true
}
fn on_serve(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
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<(), SsgError> {
for plugin in &self.plugins {
plugin.before_compile(ctx)?;
}
Ok(())
}
pub fn run_after_compile(
&self,
ctx: &PluginContext,
) -> Result<(), SsgError> {
for plugin in &self.plugins {
plugin.after_compile(ctx)?;
}
Ok(())
}
pub fn run_fused_transforms(
&self,
ctx: &PluginContext,
) -> Result<(), SsgError> {
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);
let io_pool = crate::io_pool::IoPool::new();
html_files
.par_iter()
.try_for_each(|path| -> Result<(), SsgError> {
let original = fs::read_to_string(path).with_path(path)?;
let mut html = original.clone();
for plugin in &transform_plugins {
html = plugin.transform_html(&html, path, ctx)?;
}
if html != original {
io_pool.write(path, html.into_bytes())?;
let _ = transformed
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
Ok(())
})?;
io_pool.flush()?;
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<(), SsgError> {
for plugin in &self.plugins {
plugin.on_serve(ctx)?;
}
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<(), SsgError> {
let _ = self.before.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
let _ = self.after.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn on_serve(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
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<(), SsgError> {
if self.hook == "before" {
return Err(SsgError::Io {
path: PathBuf::from("fail"),
source: std::io::Error::other("before_compile failed"),
});
}
Ok(())
}
fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
if self.hook == "after" {
return Err(SsgError::Io {
path: PathBuf::from("fail"),
source: std::io::Error::other("after_compile failed"),
});
}
Ok(())
}
fn on_serve(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
if self.hook == "serve" {
return Err(SsgError::Io {
path: PathBuf::from("fail"),
source: std::io::Error::other("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();
let dbg = format!("{err:?}");
assert!(dbg.contains("Io"), "expected Io variant, got: {dbg}");
assert!(
dbg.contains("before_compile failed"),
"source message expected: {dbg}"
);
}
#[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();
let dbg = format!("{err:?}");
assert!(dbg.contains("Io"), "expected Io variant, got: {dbg}");
assert!(
dbg.contains("after_compile failed"),
"source message expected: {dbg}"
);
}
#[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();
let dbg = format!("{err:?}");
assert!(dbg.contains("Io"), "expected Io variant, got: {dbg}");
assert!(
dbg.contains("on_serve failed"),
"source message expected: {dbg}"
);
}
#[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_needs_all_files_defaults_to_true() {
let p = NoopPlugin;
assert!(p.needs_all_files());
}
#[derive(Debug)]
struct PerFilePlugin;
impl Plugin for PerFilePlugin {
fn name(&self) -> &'static str {
"per-file"
}
fn needs_all_files(&self) -> bool {
false
}
}
#[test]
fn test_needs_all_files_can_be_overridden() {
assert!(!PerFilePlugin.needs_all_files());
assert_eq!(PerFilePlugin.name(), "per-file");
}
#[test]
fn test_fail_plugin_non_matching_hooks_succeed() {
let ctx = test_ctx();
let p = FailPlugin { hook: "before" };
assert_eq!(p.name(), "fail-plugin");
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());
}
#[derive(Debug)]
struct IdentityTransformPlugin;
impl Plugin for IdentityTransformPlugin {
fn name(&self) -> &'static str {
"identity-transform"
}
fn transform_html(
&self,
html: &str,
_path: &Path,
_ctx: &PluginContext,
) -> Result<String, SsgError> {
Ok(html.to_string())
}
fn has_transform(&self) -> bool {
true
}
}
#[derive(Debug)]
struct MarkerRewritePlugin;
impl Plugin for MarkerRewritePlugin {
fn name(&self) -> &'static str {
"marker-rewrite"
}
fn transform_html(
&self,
html: &str,
_path: &Path,
_ctx: &PluginContext,
) -> Result<String, SsgError> {
Ok(html.replace("CHANGE-ME", "CHANGED"))
}
fn has_transform(&self) -> bool {
true
}
}
#[allow(clippy::permissions_set_readonly_false)] fn set_readonly(path: &Path, readonly: bool) {
let mut perms = fs::metadata(path).unwrap().permissions();
perms.set_readonly(readonly);
fs::set_permissions(path, perms).unwrap();
}
#[test]
fn test_fused_noop_chain_rewrites_zero_files() {
let dir = tempfile::tempdir().unwrap();
let files: Vec<_> = (0..3)
.map(|i| {
let f = dir.path().join(format!("p{i}.html"));
fs::write(&f, format!("<p>page {i}</p>")).unwrap();
set_readonly(&f, true);
f
})
.collect();
assert_eq!(IdentityTransformPlugin.name(), "identity-transform");
assert_eq!(MarkerRewritePlugin.name(), "marker-rewrite");
let mut pm = PluginManager::new();
pm.register(IdentityTransformPlugin);
pm.register(MarkerRewritePlugin);
let mut ctx =
PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
ctx.cache_html_files();
pm.run_fused_transforms(&ctx).unwrap();
for (i, f) in files.iter().enumerate() {
assert_eq!(
fs::read_to_string(f).unwrap(),
format!("<p>page {i}</p>")
);
set_readonly(f, false); }
}
#[test]
fn test_fused_modifying_chain_writes_exactly_changed_files() {
let dir = tempfile::tempdir().unwrap();
let changed = dir.path().join("changed.html");
let untouched = dir.path().join("untouched.html");
fs::write(&changed, "<p>CHANGE-ME</p>").unwrap();
fs::write(&untouched, "<p>static</p>").unwrap();
set_readonly(&untouched, true);
let mut pm = PluginManager::new();
pm.register(MarkerRewritePlugin);
let mut ctx =
PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
ctx.cache_html_files();
pm.run_fused_transforms(&ctx).unwrap();
assert_eq!(fs::read_to_string(&changed).unwrap(), "<p>CHANGED</p>");
assert_eq!(fs::read_to_string(&untouched).unwrap(), "<p>static</p>");
set_readonly(&untouched, false);
}
#[test]
fn test_fused_transform_write_failure_surfaces_at_flush() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("locked.html");
fs::write(&f, "<p>CHANGE-ME</p>").unwrap();
set_readonly(&f, true);
let mut pm = PluginManager::new();
pm.register(MarkerRewritePlugin);
let mut ctx =
PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
ctx.cache_html_files();
let err = pm
.run_fused_transforms(&ctx)
.expect_err("write to read-only file must surface");
let dbg = format!("{err:?}");
assert!(dbg.contains("Io"), "expected Io variant, got: {dbg}");
set_readonly(&f, false);
}
#[test]
fn test_cache_load_unreadable_file_yields_empty_cache() {
let tmp = tempfile::tempdir().unwrap();
let cache_path = tmp.path().join(CACHE_FILENAME);
fs::write(&cache_path, [0xFF, 0xFE, 0xFD]).unwrap();
let loaded = PluginCache::load(tmp.path());
assert!(
loaded.entries.is_empty(),
"unreadable cache file should yield an empty cache"
);
}
#[test]
fn test_cache_save_write_failure_returns_io_error() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir(tmp.path().join(CACHE_FILENAME)).unwrap();
let err = PluginCache::new()
.save(tmp.path())
.expect_err("write over a directory must fail");
let dbg = format!("{err:?}");
assert!(dbg.contains("Io"), "expected Io variant, got: {dbg}");
}
#[test]
fn test_cache_html_files_missing_site_dir_leaves_cache_unset() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("missing-site");
let mut ctx =
PluginContext::new(tmp.path(), tmp.path(), &missing, tmp.path());
ctx.cache_html_files();
assert!(
ctx.html_files.is_none(),
"missing site_dir must not populate the html cache"
);
}
#[cfg(unix)]
#[test]
fn test_cache_html_files_walk_error_yields_empty_cached_list() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let site = tmp.path().join("site");
let locked = site.join("locked");
fs::create_dir_all(&locked).unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
.unwrap();
let mut ctx =
PluginContext::new(tmp.path(), tmp.path(), &site, tmp.path());
ctx.cache_html_files();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
.unwrap();
assert_eq!(
ctx.html_files.as_deref(),
Some(&Vec::new()),
"walk error must degrade to an empty cached list, not panic"
);
}
#[cfg(unix)]
#[test]
fn test_get_html_files_walk_error_returns_empty_uncached() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let site = tmp.path().join("site");
let locked = site.join("locked");
fs::create_dir_all(&locked).unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
.unwrap();
let ctx = PluginContext::new(tmp.path(), tmp.path(), &site, tmp.path());
let files = ctx.get_html_files();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
.unwrap();
assert!(files.is_empty(), "walk error must yield an empty Vec");
}
#[test]
fn test_default_transform_html_returns_input_unchanged() {
let ctx = test_ctx();
let out = NoopPlugin
.transform_html("<p>as-is</p>", Path::new("x.html"), &ctx)
.unwrap();
assert_eq!(out, "<p>as-is</p>");
}
#[test]
fn test_fused_without_transform_plugins_is_trivial_ok() {
let mut pm = PluginManager::new();
pm.register(NoopPlugin);
let ctx = test_ctx();
pm.run_fused_transforms(&ctx).unwrap();
}
#[test]
fn test_fused_read_failure_on_invalid_utf8_surfaces() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("broken.html"), [0xFF, 0xFE, 0xFD]).unwrap();
let mut pm = PluginManager::new();
pm.register(IdentityTransformPlugin);
let mut ctx =
PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
ctx.cache_html_files();
let err = pm
.run_fused_transforms(&ctx)
.expect_err("invalid UTF-8 html must surface a read error");
let dbg = format!("{err:?}");
assert!(dbg.contains("broken.html"), "path context expected: {dbg}");
}
#[derive(Debug)]
struct FailingTransformPlugin;
impl Plugin for FailingTransformPlugin {
fn name(&self) -> &'static str {
"failing-transform"
}
fn transform_html(
&self,
_html: &str,
path: &Path,
_ctx: &PluginContext,
) -> Result<String, SsgError> {
Err(SsgError::Io {
path: path.to_path_buf(),
source: std::io::Error::other("transform_html failed"),
})
}
fn has_transform(&self) -> bool {
true
}
}
#[test]
fn test_fused_transform_error_stops_the_pass() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("page.html"), "<p>x</p>").unwrap();
let mut pm = PluginManager::new();
pm.register(FailingTransformPlugin);
assert_eq!(FailingTransformPlugin.name(), "failing-transform");
let mut ctx =
PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
ctx.cache_html_files();
let err = pm
.run_fused_transforms(&ctx)
.expect_err("failing transform plugin must surface its error");
let dbg = format!("{err:?}");
assert!(
dbg.contains("transform_html failed"),
"plugin error expected: {dbg}"
);
assert_eq!(
fs::read_to_string(dir.path().join("page.html")).unwrap(),
"<p>x</p>"
);
}
}