1use std::collections::HashMap;
2
3use crate::context::{
4 BuildContext, ContentContext, GraphContext, InitContext, OutputContext, RenderContext,
5 ValidationContext, WatchContext,
6};
7use crate::traits::Plugin;
8
9pub 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 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 pub fn register_all(&mut self, plugins: Vec<Box<dyn Plugin>>) {
29 for plugin in plugins {
30 self.register(plugin);
31 }
32 }
33
34 pub fn len(&self) -> usize {
36 self.plugins.len()
37 }
38
39 pub fn is_empty(&self) -> bool {
41 self.plugins.is_empty()
42 }
43
44 pub fn plugin_names(&self) -> Vec<&str> {
46 self.plugins.iter().map(|p| p.name()).collect()
47 }
48
49 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 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 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 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 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 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 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 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}