1use std::collections::{HashMap, HashSet};
14use std::sync::{OnceLock, RwLock};
15
16pub struct Asset {
24 pub url: String,
26 pub integrity: Option<String>,
28 pub crossorigin: Option<String>,
30}
31
32impl Asset {
33 pub fn new(url: impl Into<String>) -> Self {
35 Self {
36 url: url.into(),
37 integrity: None,
38 crossorigin: None,
39 }
40 }
41
42 pub fn integrity(mut self, hash: impl Into<String>) -> Self {
44 self.integrity = Some(hash.into());
45 self
46 }
47
48 pub fn crossorigin(mut self, value: impl Into<String>) -> Self {
50 self.crossorigin = Some(value.into());
51 self
52 }
53}
54
55pub trait JsonUiPlugin: Send + Sync {
67 fn component_type(&self) -> &str;
72
73 fn props_schema(&self) -> serde_json::Value;
78
79 fn render(&self, props: &serde_json::Value, data: &serde_json::Value) -> String;
83
84 fn css_assets(&self) -> Vec<Asset>;
89
90 fn js_assets(&self) -> Vec<Asset>;
95
96 fn init_script(&self) -> Option<String>;
100}
101
102pub struct PluginRegistry {
109 plugins: HashMap<String, Box<dyn JsonUiPlugin>>,
110}
111
112impl PluginRegistry {
113 pub fn new() -> Self {
115 Self {
116 plugins: HashMap::new(),
117 }
118 }
119
120 pub fn register(&mut self, plugin: impl JsonUiPlugin + 'static) {
122 let name = plugin.component_type().to_string();
123 self.plugins.insert(name, Box::new(plugin));
124 }
125
126 pub fn get(&self, component_type: &str) -> Option<&dyn JsonUiPlugin> {
128 self.plugins.get(component_type).map(|p| p.as_ref())
129 }
130
131 pub fn registered_types(&self) -> Vec<String> {
133 let mut types: Vec<String> = self.plugins.keys().cloned().collect();
134 types.sort();
135 types
136 }
137}
138
139impl Default for PluginRegistry {
140 fn default() -> Self {
141 Self::new()
142 }
143}
144
145static GLOBAL_PLUGIN_REGISTRY: OnceLock<RwLock<PluginRegistry>> = OnceLock::new();
148
149pub fn global_plugin_registry() -> &'static RwLock<PluginRegistry> {
153 GLOBAL_PLUGIN_REGISTRY.get_or_init(|| {
154 let mut registry = PluginRegistry::new();
155 registry.register(crate::plugins::MapPlugin);
156 RwLock::new(registry)
157 })
158}
159
160pub fn register_plugin(plugin: impl JsonUiPlugin + 'static) {
164 global_plugin_registry()
165 .write()
166 .expect("plugin registry poisoned")
167 .register(plugin);
168}
169
170pub fn with_plugin<R>(component_type: &str, f: impl FnOnce(&dyn JsonUiPlugin) -> R) -> Option<R> {
179 let guard = global_plugin_registry()
180 .read()
181 .expect("plugin registry poisoned");
182 guard.get(component_type).map(f)
183}
184
185pub fn registered_plugin_types() -> Vec<String> {
189 global_plugin_registry()
190 .read()
191 .expect("plugin registry poisoned")
192 .registered_types()
193}
194
195pub struct CollectedAssets {
199 pub css: Vec<Asset>,
201 pub js: Vec<Asset>,
203 pub init_scripts: Vec<String>,
205}
206
207pub fn collect_plugin_assets(plugin_types: &[String]) -> CollectedAssets {
213 let registry = global_plugin_registry()
214 .read()
215 .expect("plugin registry poisoned");
216
217 let mut css_seen = HashSet::new();
218 let mut js_seen = HashSet::new();
219 let mut css = Vec::new();
220 let mut js = Vec::new();
221 let mut init_scripts = Vec::new();
222
223 for type_name in plugin_types {
224 if let Some(plugin) = registry.get(type_name) {
225 for asset in plugin.css_assets() {
226 if css_seen.insert(asset.url.clone()) {
227 css.push(asset);
228 }
229 }
230 for asset in plugin.js_assets() {
231 if js_seen.insert(asset.url.clone()) {
232 js.push(asset);
233 }
234 }
235 if let Some(script) = plugin.init_script() {
236 init_scripts.push(script);
237 }
238 }
239 }
240
241 CollectedAssets {
242 css,
243 js,
244 init_scripts,
245 }
246}
247
248#[cfg(test)]
251mod tests {
252 use super::*;
253
254 struct TestPlugin;
256
257 impl JsonUiPlugin for TestPlugin {
258 fn component_type(&self) -> &str {
259 "TestWidget"
260 }
261
262 fn props_schema(&self) -> serde_json::Value {
263 serde_json::json!({
264 "type": "object",
265 "properties": {
266 "label": { "type": "string" }
267 }
268 })
269 }
270
271 fn render(&self, props: &serde_json::Value, _data: &serde_json::Value) -> String {
272 let label = props
273 .get("label")
274 .and_then(|v| v.as_str())
275 .unwrap_or("default");
276 format!("<div class=\"test-widget\">{label}</div>")
277 }
278
279 fn css_assets(&self) -> Vec<Asset> {
280 vec![Asset::new("https://cdn.example.com/widget.css")
281 .integrity("sha256-abc123")
282 .crossorigin("")]
283 }
284
285 fn js_assets(&self) -> Vec<Asset> {
286 vec![Asset::new("https://cdn.example.com/widget.js")]
287 }
288
289 fn init_script(&self) -> Option<String> {
290 Some("initWidgets();".to_string())
291 }
292 }
293
294 struct NoAssetPlugin;
295
296 impl JsonUiPlugin for NoAssetPlugin {
297 fn component_type(&self) -> &str {
298 "NoAsset"
299 }
300
301 fn props_schema(&self) -> serde_json::Value {
302 serde_json::json!({})
303 }
304
305 fn render(&self, _props: &serde_json::Value, _data: &serde_json::Value) -> String {
306 "<span>no-asset</span>".to_string()
307 }
308
309 fn css_assets(&self) -> Vec<Asset> {
310 vec![]
311 }
312
313 fn js_assets(&self) -> Vec<Asset> {
314 vec![]
315 }
316
317 fn init_script(&self) -> Option<String> {
318 None
319 }
320 }
321
322 #[test]
325 fn asset_builder_sets_all_fields() {
326 let asset = Asset::new("https://example.com/lib.js")
327 .integrity("sha256-xyz")
328 .crossorigin("anonymous");
329
330 assert_eq!(asset.url, "https://example.com/lib.js");
331 assert_eq!(asset.integrity.as_deref(), Some("sha256-xyz"));
332 assert_eq!(asset.crossorigin.as_deref(), Some("anonymous"));
333 }
334
335 #[test]
336 fn asset_new_has_no_integrity_or_crossorigin() {
337 let asset = Asset::new("https://example.com/lib.js");
338 assert!(asset.integrity.is_none());
339 assert!(asset.crossorigin.is_none());
340 }
341
342 #[test]
345 fn registry_starts_empty() {
346 let registry = PluginRegistry::new();
347 assert!(registry.registered_types().is_empty());
348 }
349
350 #[test]
351 fn registry_register_and_get() {
352 let mut registry = PluginRegistry::new();
353 registry.register(TestPlugin);
354
355 let plugin = registry.get("TestWidget");
356 assert!(plugin.is_some());
357 assert_eq!(plugin.unwrap().component_type(), "TestWidget");
358 }
359
360 #[test]
361 fn registry_get_returns_none_for_unknown() {
362 let registry = PluginRegistry::new();
363 assert!(registry.get("NonExistent").is_none());
364 }
365
366 #[test]
367 fn registry_registered_types_sorted() {
368 let mut registry = PluginRegistry::new();
369 registry.register(TestPlugin);
370 registry.register(NoAssetPlugin);
371
372 let types = registry.registered_types();
373 assert_eq!(types, vec!["NoAsset", "TestWidget"]);
374 }
375
376 #[test]
377 fn registry_register_replaces_existing() {
378 let mut registry = PluginRegistry::new();
379
380 struct PluginV1;
381 impl JsonUiPlugin for PluginV1 {
382 fn component_type(&self) -> &str {
383 "Same"
384 }
385 fn props_schema(&self) -> serde_json::Value {
386 serde_json::json!({"v": 1})
387 }
388 fn render(&self, _: &serde_json::Value, _: &serde_json::Value) -> String {
389 "v1".to_string()
390 }
391 fn css_assets(&self) -> Vec<Asset> {
392 vec![]
393 }
394 fn js_assets(&self) -> Vec<Asset> {
395 vec![]
396 }
397 fn init_script(&self) -> Option<String> {
398 None
399 }
400 }
401
402 struct PluginV2;
403 impl JsonUiPlugin for PluginV2 {
404 fn component_type(&self) -> &str {
405 "Same"
406 }
407 fn props_schema(&self) -> serde_json::Value {
408 serde_json::json!({"v": 2})
409 }
410 fn render(&self, _: &serde_json::Value, _: &serde_json::Value) -> String {
411 "v2".to_string()
412 }
413 fn css_assets(&self) -> Vec<Asset> {
414 vec![]
415 }
416 fn js_assets(&self) -> Vec<Asset> {
417 vec![]
418 }
419 fn init_script(&self) -> Option<String> {
420 None
421 }
422 }
423
424 registry.register(PluginV1);
425 registry.register(PluginV2);
426
427 let plugin = registry.get("Same").unwrap();
428 let html = plugin.render(&serde_json::json!({}), &serde_json::json!({}));
429 assert_eq!(html, "v2");
430 }
431
432 #[test]
435 fn plugin_renders_html() {
436 let plugin = TestPlugin;
437 let html = plugin.render(
438 &serde_json::json!({"label": "Hello"}),
439 &serde_json::json!({}),
440 );
441 assert_eq!(html, "<div class=\"test-widget\">Hello</div>");
442 }
443
444 #[test]
445 fn plugin_returns_schema() {
446 let plugin = TestPlugin;
447 let schema = plugin.props_schema();
448 assert_eq!(schema["type"], "object");
449 assert!(schema["properties"]["label"].is_object());
450 }
451
452 #[test]
455 fn collect_assets_from_registry() {
456 register_plugin(TestPlugin);
458 register_plugin(NoAssetPlugin);
459
460 let assets = collect_plugin_assets(&["TestWidget".to_string()]);
461 assert_eq!(assets.css.len(), 1);
462 assert_eq!(assets.css[0].url, "https://cdn.example.com/widget.css");
463 assert_eq!(assets.js.len(), 1);
464 assert_eq!(assets.js[0].url, "https://cdn.example.com/widget.js");
465 assert_eq!(assets.init_scripts.len(), 1);
466 assert_eq!(assets.init_scripts[0], "initWidgets();");
467 }
468
469 #[test]
470 fn collect_assets_deduplicates_by_url() {
471 register_plugin(TestPlugin);
473
474 let assets = collect_plugin_assets(&["TestWidget".to_string(), "TestWidget".to_string()]);
476 assert_eq!(assets.css.len(), 1);
477 assert_eq!(assets.js.len(), 1);
478 }
479
480 #[test]
481 fn collect_assets_empty_for_unknown_types() {
482 let assets = collect_plugin_assets(&["NonExistentPlugin".to_string()]);
483 assert!(assets.css.is_empty());
484 assert!(assets.js.is_empty());
485 assert!(assets.init_scripts.is_empty());
486 }
487
488 #[test]
489 fn collect_assets_handles_no_asset_plugin() {
490 register_plugin(NoAssetPlugin);
491 let assets = collect_plugin_assets(&["NoAsset".to_string()]);
492 assert!(assets.css.is_empty());
493 assert!(assets.js.is_empty());
494 assert!(assets.init_scripts.is_empty());
495 }
496
497 #[test]
500 fn global_registry_returns_valid_registry() {
501 let reg = global_plugin_registry();
502 let guard = reg.read().unwrap();
503 let _ = guard.registered_types();
505 }
506
507 #[test]
508 fn registered_plugin_types_returns_sorted_list() {
509 let types = registered_plugin_types();
511 let mut sorted = types.clone();
514 sorted.sort();
515 assert_eq!(types, sorted);
516 }
517
518 #[test]
521 fn test_map_plugin_full_pipeline() {
522 use crate::component::{Component, ComponentNode, PluginProps};
523 use crate::render::render_to_html_with_plugins;
524 use crate::view::JsonUiView;
525
526 let view = JsonUiView::new().component(ComponentNode {
528 key: "map-1".to_string(),
529 component: Component::Plugin(PluginProps {
530 plugin_type: "Map".to_string(),
531 props: serde_json::json!({
532 "center": [51.505, -0.09],
533 "zoom": 12
534 }),
535 }),
536 action: None,
537 visibility: None,
538 });
539
540 let result = render_to_html_with_plugins(&view, &serde_json::json!({}));
541
542 assert!(
544 result.html.contains("data-ferro-map"),
545 "rendered HTML should contain map container"
546 );
547 assert!(
548 result.html.contains("51.505"),
549 "rendered HTML should contain center lat"
550 );
551
552 assert!(
554 result.css_head.contains("leaflet"),
555 "CSS head should contain Leaflet link"
556 );
557
558 assert!(
560 result.scripts.contains("leaflet"),
561 "scripts should contain Leaflet JS"
562 );
563 }
564
565 #[test]
566 fn test_plugin_assets_deduplication() {
567 use crate::component::{Component, ComponentNode, PluginProps};
568 use crate::render::render_to_html_with_plugins;
569 use crate::view::JsonUiView;
570
571 let view = JsonUiView::new()
573 .component(ComponentNode {
574 key: "map-a".to_string(),
575 component: Component::Plugin(PluginProps {
576 plugin_type: "Map".to_string(),
577 props: serde_json::json!({"center": [40.7128, -74.0060], "zoom": 12}),
578 }),
579 action: None,
580 visibility: None,
581 })
582 .component(ComponentNode {
583 key: "map-b".to_string(),
584 component: Component::Plugin(PluginProps {
585 plugin_type: "Map".to_string(),
586 props: serde_json::json!({"center": [51.505, -0.09], "zoom": 10}),
587 }),
588 action: None,
589 visibility: None,
590 });
591
592 let result = render_to_html_with_plugins(&view, &serde_json::json!({}));
593
594 assert!(result.html.contains("40.7128"), "first map center rendered");
596 assert!(result.html.contains("51.505"), "second map center rendered");
597
598 let css_count = result.css_head.matches("leaflet.css").count();
600 assert_eq!(css_count, 1, "Leaflet CSS should appear exactly once");
601
602 let js_count = result.scripts.matches("leaflet.js").count();
604 assert_eq!(js_count, 1, "Leaflet JS should appear exactly once");
605 }
606}