1use crate::cmd::SsgConfig;
41use anyhow::{Context, Result};
42use std::{
43 collections::HashMap,
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: HashMap<PathBuf, u64>,
63}
64
65impl PluginCache {
66 #[must_use]
68 pub fn new() -> Self {
69 Self {
70 entries: HashMap::new(),
71 }
72 }
73
74 #[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 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 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 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 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#[derive(Debug, Clone)]
148pub struct PluginContext {
149 pub content_dir: PathBuf,
151 pub build_dir: PathBuf,
153 pub site_dir: PathBuf,
155 pub template_dir: PathBuf,
157 pub config: Option<SsgConfig>,
159 pub cache: Option<PluginCache>,
161 pub memory_budget: Option<crate::streaming::MemoryBudget>,
163 pub html_files: Option<Arc<Vec<PathBuf>>>,
166 pub dep_graph: Option<crate::depgraph::DepGraph>,
168}
169
170impl PluginContext {
171 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 #[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 #[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 #[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
237pub trait Plugin: fmt::Debug + Send + Sync {
266 fn name(&self) -> &str;
268
269 fn before_compile(&self, _ctx: &PluginContext) -> Result<()> {
274 Ok(())
275 }
276
277 fn after_compile(&self, _ctx: &PluginContext) -> Result<()> {
282 Ok(())
283 }
284
285 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 fn has_transform(&self) -> bool {
306 false
307 }
308
309 fn on_serve(&self, _ctx: &PluginContext) -> Result<()> {
314 Ok(())
315 }
316}
317
318#[derive(Debug, Default)]
351pub struct PluginManager {
352 plugins: Vec<Box<dyn Plugin>>,
353}
354
355impl PluginManager {
356 #[must_use]
358 pub fn new() -> Self {
359 Self {
360 plugins: Vec::new(),
361 }
362 }
363
364 pub fn register<P: Plugin + 'static>(&mut self, plugin: P) {
368 self.plugins.push(Box::new(plugin));
369 }
370
371 #[must_use]
373 pub fn len(&self) -> usize {
374 self.plugins.len()
375 }
376
377 #[must_use]
379 pub fn is_empty(&self) -> bool {
380 self.plugins.is_empty()
381 }
382
383 #[must_use]
385 pub fn names(&self) -> Vec<&str> {
386 self.plugins.iter().map(|p| p.name()).collect()
387 }
388
389 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 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 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 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 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 #[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 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 let cache_path = tmp.path().join(CACHE_FILENAME);
793 assert!(cache_path.exists(), "Cache file should be persisted");
794
795 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 #[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 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 fs::write(&f, "version2").unwrap();
885 assert!(cache.has_changed(&f));
886
887 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 let p = FailPlugin { hook: "before" };
955 assert!(p.after_compile(&ctx).is_ok());
956 assert!(p.on_serve(&ctx).is_ok());
957
958 let p = FailPlugin { hook: "after" };
960 assert!(p.before_compile(&ctx).is_ok());
961 assert!(p.on_serve(&ctx).is_ok());
962
963 let p = FailPlugin { hook: "serve" };
965 assert!(p.before_compile(&ctx).is_ok());
966 assert!(p.after_compile(&ctx).is_ok());
967 }
968}