1use 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
49const CACHE_FILENAME: &str = ".ssg-plugin-cache.json";
54
55#[derive(Debug, Clone, Default)]
61pub struct PluginCache {
62 entries: BTreeMap<PathBuf, u64>,
63}
64
65impl PluginCache {
66 #[must_use]
78 pub const fn new() -> Self {
79 Self {
80 entries: BTreeMap::new(),
81 }
82 }
83
84 #[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 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 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 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 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 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#[derive(Debug, Clone)]
219pub struct PluginContext {
220 pub content_dir: PathBuf,
222 pub build_dir: PathBuf,
224 pub site_dir: PathBuf,
226 pub template_dir: PathBuf,
228 pub config: Option<SsgConfig>,
230 pub cache: Option<PluginCache>,
232 pub memory_budget: Option<crate::streaming::MemoryBudget>,
234 pub html_files: Option<Arc<Vec<PathBuf>>>,
237 pub dep_graph: Option<crate::depgraph::DepGraph>,
239 pub dry_run: bool,
244}
245
246impl PluginContext {
247 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 #[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 #[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 #[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 #[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
392pub trait Plugin: fmt::Debug + Send + Sync {
421 fn name(&self) -> &str;
423
424 fn before_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
429 Ok(())
430 }
431
432 fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
437 Ok(())
438 }
439
440 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 fn has_transform(&self) -> bool {
461 false
462 }
463
464 fn needs_all_files(&self) -> bool {
475 true
476 }
477
478 fn on_serve(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
483 Ok(())
484 }
485}
486
487#[derive(Debug, Default)]
520pub struct PluginManager {
521 plugins: Vec<Box<dyn Plugin>>,
522}
523
524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531pub struct PluginInfo<'a> {
532 pub order: usize,
534 pub name: &'a str,
536 pub has_transform: bool,
538 pub needs_all_files: bool,
540}
541
542impl PluginManager {
543 #[must_use]
554 pub fn new() -> Self {
555 Self {
556 plugins: Vec::new(),
557 }
558 }
559
560 pub fn register<P: Plugin + 'static>(&mut self, plugin: P) {
575 self.plugins.push(Box::new(plugin));
576 }
577
578 #[must_use]
589 pub fn len(&self) -> usize {
590 self.plugins.len()
591 }
592
593 #[must_use]
603 pub fn is_empty(&self) -> bool {
604 self.plugins.is_empty()
605 }
606
607 #[must_use]
620 pub fn names(&self) -> Vec<&str> {
621 self.plugins.iter().map(|p| p.name()).collect()
622 }
623
624 #[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 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 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 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 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 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 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 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 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 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 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 #[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 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 let cache_path = tmp.path().join(CACHE_FILENAME);
1156 assert!(cache_path.exists(), "Cache file should be persisted");
1157
1158 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 #[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 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 fs::write(&f, "version2").unwrap();
1248 assert!(cache.has_changed(&f));
1249
1250 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 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 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 let p = FailPlugin { hook: "after" };
1349 assert!(p.before_compile(&ctx).is_ok());
1350 assert!(p.on_serve(&ctx).is_ok());
1351
1352 let p = FailPlugin { hook: "serve" };
1354 assert!(p.before_compile(&ctx).is_ok());
1355 assert!(p.after_compile(&ctx).is_ok());
1356 }
1357
1358 #[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 #[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 #[allow(clippy::permissions_set_readonly_false)] 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 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); let mut ctx =
1431 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
1432 ctx.cache_html_files();
1433
1434 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); }
1444 }
1445
1446 #[test]
1447 fn test_fused_modifying_chain_writes_exactly_changed_files() {
1448 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 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 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 #[test]
1503 fn test_cache_load_unreadable_file_yields_empty_cache() {
1504 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 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 #[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 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 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 #[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 #[test]
1618 fn test_fused_without_transform_plugins_is_trivial_ok() {
1619 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 #[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 assert_eq!(
1692 fs::read_to_string(dir.path().join("page.html")).unwrap(),
1693 "<p>x</p>"
1694 );
1695 }
1696}