Skip to main content

geoff_plugin/
registry.rs

1use std::collections::HashMap;
2
3use crate::context::{
4    BuildContext, ContentContext, GraphContext, InitContext, OutputContext, RenderContext,
5    ValidationContext, WatchContext,
6};
7use crate::traits::Plugin;
8
9/// Manages loaded plugins and dispatches lifecycle events in registration order.
10pub struct PluginRegistry {
11    plugins: Vec<Box<dyn Plugin>>,
12}
13
14impl PluginRegistry {
15    pub fn new() -> Self {
16        Self {
17            plugins: Vec::new(),
18        }
19    }
20
21    /// Register a plugin. Plugins are dispatched in registration order.
22    pub fn register(&mut self, plugin: Box<dyn Plugin>) {
23        tracing::info!(name = plugin.name(), "registered plugin");
24        self.plugins.push(plugin);
25    }
26
27    /// Register multiple plugins at once, preserving order.
28    pub fn register_all(&mut self, plugins: Vec<Box<dyn Plugin>>) {
29        for plugin in plugins {
30            self.register(plugin);
31        }
32    }
33
34    /// Returns the number of registered plugins.
35    pub fn len(&self) -> usize {
36        self.plugins.len()
37    }
38
39    /// Returns true if no plugins are registered.
40    pub fn is_empty(&self) -> bool {
41        self.plugins.is_empty()
42    }
43
44    /// Returns the names of all registered plugins.
45    pub fn plugin_names(&self) -> Vec<&str> {
46        self.plugins.iter().map(|p| p.name()).collect()
47    }
48
49    // ── Lifecycle dispatchers ────────────────────────────────────────
50
51    /// Dispatch `on_init` to all plugins in order.
52    pub async fn dispatch_init(
53        &self,
54        config: &geoff_core::config::SiteConfig,
55        plugin_options: &HashMap<String, HashMap<String, toml::Value>>,
56    ) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
57        for plugin in &self.plugins {
58            let empty = HashMap::new();
59            let opts = plugin_options.get(plugin.name()).unwrap_or(&empty);
60            let mut ctx = InitContext {
61                config,
62                plugin_options: opts,
63            };
64            plugin.on_init(&mut ctx).await?;
65        }
66        Ok(())
67    }
68
69    /// Dispatch `on_build_start` to all plugins in order.
70    pub async fn dispatch_build_start(
71        &self,
72        config: &geoff_core::config::SiteConfig,
73        store: &geoff_graph::store::ContentStore,
74    ) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
75        for plugin in &self.plugins {
76            let mut ctx = BuildContext { config, store };
77            plugin.on_build_start(&mut ctx).await?;
78        }
79        Ok(())
80    }
81
82    /// Dispatch `on_content_parsed` to all plugins in order for a single page.
83    pub async fn dispatch_content_parsed(
84        &self,
85        config: &geoff_core::config::SiteConfig,
86        page: &mut crate::context::PageData,
87    ) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
88        for plugin in &self.plugins {
89            let mut ctx = ContentContext { config, page };
90            plugin.on_content_parsed(&mut ctx).await?;
91        }
92        Ok(())
93    }
94
95    /// Dispatch `on_graph_updated` to all plugins in order.
96    pub async fn dispatch_graph_updated(
97        &self,
98        config: &geoff_core::config::SiteConfig,
99        store: &geoff_graph::store::ContentStore,
100    ) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
101        for plugin in &self.plugins {
102            let mut ctx = GraphContext { config, store };
103            plugin.on_graph_updated(&mut ctx).await?;
104        }
105        Ok(())
106    }
107
108    /// Dispatch `on_validation_complete` to all plugins in order.
109    pub async fn dispatch_validation_complete(
110        &self,
111        config: &geoff_core::config::SiteConfig,
112        store: &geoff_graph::store::ContentStore,
113        conforms: bool,
114        violations: usize,
115    ) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
116        for plugin in &self.plugins {
117            let mut ctx = ValidationContext {
118                config,
119                store,
120                conforms,
121                violations,
122            };
123            plugin.on_validation_complete(&mut ctx).await?;
124        }
125        Ok(())
126    }
127
128    /// Dispatch `on_page_render` to all plugins in order for a single page.
129    pub async fn dispatch_page_render(
130        &self,
131        config: &geoff_core::config::SiteConfig,
132        store: &geoff_graph::store::ContentStore,
133        page: &mut crate::context::PageData,
134        extra_vars: &mut HashMap<String, serde_json::Value>,
135    ) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
136        for plugin in &self.plugins {
137            let mut ctx = RenderContext {
138                config,
139                store,
140                page,
141                extra_vars,
142            };
143            plugin.on_page_render(&mut ctx).await?;
144        }
145        Ok(())
146    }
147
148    /// Dispatch `on_build_complete` to all plugins in order.
149    pub async fn dispatch_build_complete(
150        &self,
151        config: &geoff_core::config::SiteConfig,
152        store: &geoff_graph::store::ContentStore,
153        outputs: &HashMap<String, String>,
154        output_dir: &camino::Utf8Path,
155    ) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
156        for plugin in &self.plugins {
157            let mut ctx = OutputContext {
158                config,
159                store,
160                outputs,
161                output_dir,
162            };
163            plugin.on_build_complete(&mut ctx).await?;
164        }
165        Ok(())
166    }
167
168    /// Dispatch `on_file_changed` to all plugins in order.
169    pub async fn dispatch_file_changed(
170        &self,
171        config: &geoff_core::config::SiteConfig,
172        changed_path: &str,
173    ) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
174        for plugin in &self.plugins {
175            let mut ctx = WatchContext {
176                config,
177                changed_path,
178            };
179            plugin.on_file_changed(&mut ctx).await?;
180        }
181        Ok(())
182    }
183}
184
185impl Default for PluginRegistry {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::traits::Plugin;
195    use async_trait::async_trait;
196    use std::sync::Arc;
197    use std::sync::atomic::{AtomicUsize, Ordering};
198
199    struct TestPlugin {
200        name: String,
201        init_count: Arc<AtomicUsize>,
202        build_start_count: Arc<AtomicUsize>,
203    }
204
205    impl TestPlugin {
206        fn new(name: &str) -> Self {
207            Self {
208                name: name.to_string(),
209                init_count: Arc::new(AtomicUsize::new(0)),
210                build_start_count: Arc::new(AtomicUsize::new(0)),
211            }
212        }
213    }
214
215    #[async_trait]
216    impl Plugin for TestPlugin {
217        fn name(&self) -> &str {
218            &self.name
219        }
220
221        async fn on_init(
222            &self,
223            _ctx: &mut InitContext<'_>,
224        ) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
225            self.init_count.fetch_add(1, Ordering::SeqCst);
226            Ok(())
227        }
228
229        async fn on_build_start(
230            &self,
231            _ctx: &mut BuildContext<'_>,
232        ) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
233            self.build_start_count.fetch_add(1, Ordering::SeqCst);
234            Ok(())
235        }
236    }
237
238    #[test]
239    fn registry_register_and_names() {
240        let mut registry = PluginRegistry::new();
241        assert!(registry.is_empty());
242
243        registry.register(Box::new(TestPlugin::new("alpha")));
244        registry.register(Box::new(TestPlugin::new("beta")));
245
246        assert_eq!(registry.len(), 2);
247        assert!(!registry.is_empty());
248        assert_eq!(registry.plugin_names(), vec!["alpha", "beta"]);
249    }
250
251    #[test]
252    fn registry_default() {
253        let registry = PluginRegistry::default();
254        assert!(registry.is_empty());
255    }
256
257    #[tokio::test]
258    async fn dispatch_init_calls_all_plugins() {
259        let mut registry = PluginRegistry::new();
260
261        let p1 = TestPlugin::new("p1");
262        let p1_count = Arc::clone(&p1.init_count);
263        let p2 = TestPlugin::new("p2");
264        let p2_count = Arc::clone(&p2.init_count);
265
266        registry.register(Box::new(p1));
267        registry.register(Box::new(p2));
268
269        let config = geoff_core::config::SiteConfig {
270            base_url: "https://example.com".to_string(),
271            title: "Test".to_string(),
272            content_dir: "content".into(),
273            output_dir: "dist".into(),
274            template_dir: "templates".into(),
275            plugins: vec![],
276            search: Default::default(),
277            theme: Default::default(),
278            devspaces: Default::default(),
279            build: Default::default(),
280            linked_data: Default::default(),
281            design: Default::default(),
282            mcp: Default::default(),
283        };
284
285        let opts = HashMap::new();
286        registry.dispatch_init(&config, &opts).await.unwrap();
287
288        assert_eq!(p1_count.load(Ordering::SeqCst), 1);
289        assert_eq!(p2_count.load(Ordering::SeqCst), 1);
290    }
291
292    #[tokio::test]
293    async fn dispatch_build_start_calls_all_plugins() {
294        let mut registry = PluginRegistry::new();
295
296        let p1 = TestPlugin::new("p1");
297        let p1_count = Arc::clone(&p1.build_start_count);
298
299        registry.register(Box::new(p1));
300
301        let config = geoff_core::config::SiteConfig {
302            base_url: "https://example.com".to_string(),
303            title: "Test".to_string(),
304            content_dir: "content".into(),
305            output_dir: "dist".into(),
306            template_dir: "templates".into(),
307            plugins: vec![],
308            search: Default::default(),
309            theme: Default::default(),
310            devspaces: Default::default(),
311            build: Default::default(),
312            linked_data: Default::default(),
313            design: Default::default(),
314            mcp: Default::default(),
315        };
316
317        let store = geoff_graph::store::ContentStore::new().expect("failed to create store");
318
319        registry
320            .dispatch_build_start(&config, &store)
321            .await
322            .unwrap();
323
324        assert_eq!(p1_count.load(Ordering::SeqCst), 1);
325    }
326}