1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::sync::Mutex;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct PluginMetadata {
12 pub name: String,
13 pub version: String,
14 pub description: String,
15 pub author: String,
16 pub license: String,
17 pub homepage: Option<String>,
18 pub repository: Option<String>,
19 pub tags: Vec<String>,
20 pub category: PluginCategory,
21 pub compatibility: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
26#[serde(rename_all = "lowercase")]
27pub enum PluginCategory {
28 Auth,
29 Storage,
30 Integration,
31 Analytics,
32 Billing,
33 Communication,
34 Security,
35 DevTools,
36 Other,
37}
38
39impl PluginCategory {
40 pub fn as_str(&self) -> &str {
41 match self {
42 Self::Auth => "auth",
43 Self::Storage => "storage",
44 Self::Integration => "integration",
45 Self::Analytics => "analytics",
46 Self::Billing => "billing",
47 Self::Communication => "communication",
48 Self::Security => "security",
49 Self::DevTools => "devtools",
50 Self::Other => "other",
51 }
52 }
53
54 pub fn label(&self) -> &str {
56 match self {
57 Self::Auth => "Auth",
58 Self::Storage => "Storage",
59 Self::Integration => "Integration",
60 Self::Analytics => "Analytics",
61 Self::Billing => "Billing",
62 Self::Communication => "Communication",
63 Self::Security => "Security",
64 Self::DevTools => "Dev Tools",
65 Self::Other => "Other",
66 }
67 }
68
69 pub fn all_ordered() -> &'static [PluginCategory] {
71 &[
72 Self::Auth,
73 Self::Storage,
74 Self::Integration,
75 Self::Analytics,
76 Self::Security,
77 Self::DevTools,
78 Self::Billing,
79 Self::Communication,
80 Self::Other,
81 ]
82 }
83}
84
85pub struct PluginMarketplace {
94 plugins: Mutex<HashMap<String, PluginMetadata>>,
95}
96
97impl PluginMarketplace {
98 pub fn new() -> Self {
99 Self {
100 plugins: Mutex::new(HashMap::new()),
101 }
102 }
103
104 pub fn publish(&self, metadata: PluginMetadata) -> Result<(), String> {
109 if metadata.name.is_empty() {
110 return Err("plugin name must not be empty".into());
111 }
112 if metadata.version.is_empty() {
113 return Err("plugin version must not be empty".into());
114 }
115
116 let mut plugins = self.plugins.lock().unwrap();
117 if plugins.contains_key(&metadata.name) {
118 return Err(format!("plugin \"{}\" is already published", metadata.name));
119 }
120 plugins.insert(metadata.name.clone(), metadata);
121 Ok(())
122 }
123
124 pub fn search(&self, query: &str) -> Vec<PluginMetadata> {
128 let q = query.to_lowercase();
129 let plugins = self.plugins.lock().unwrap();
130 plugins
131 .values()
132 .filter(|p| {
133 p.name.to_lowercase().contains(&q)
134 || p.description.to_lowercase().contains(&q)
135 || p.tags.iter().any(|t| t.to_lowercase().contains(&q))
136 })
137 .cloned()
138 .collect()
139 }
140
141 pub fn by_category(&self, category: PluginCategory) -> Vec<PluginMetadata> {
143 let plugins = self.plugins.lock().unwrap();
144 plugins
145 .values()
146 .filter(|p| p.category == category)
147 .cloned()
148 .collect()
149 }
150
151 pub fn get(&self, name: &str) -> Option<PluginMetadata> {
153 self.plugins.lock().unwrap().get(name).cloned()
154 }
155
156 pub fn list_all(&self) -> Vec<PluginMetadata> {
158 self.plugins.lock().unwrap().values().cloned().collect()
159 }
160
161 pub fn unpublish(&self, name: &str) -> bool {
163 self.plugins.lock().unwrap().remove(name).is_some()
164 }
165
166 pub fn count(&self) -> usize {
168 self.plugins.lock().unwrap().len()
169 }
170
171 pub fn seed_builtins(&self) {
173 let builtins = vec![
174 PluginMetadata {
176 name: "password-auth".into(),
177 version: "0.1.0".into(),
178 description: "Secure password hashing with salt".into(),
179 author: "pylon".into(),
180 license: "MIT".into(),
181 homepage: None,
182 repository: None,
183 tags: vec!["auth".into(), "password".into(), "hashing".into()],
184 category: PluginCategory::Auth,
185 compatibility: ">=0.1.0".into(),
186 },
187 PluginMetadata {
188 name: "session-expiry".into(),
189 version: "0.1.0".into(),
190 description: "Session lifetime with idle timeout".into(),
191 author: "pylon".into(),
192 license: "MIT".into(),
193 homepage: None,
194 repository: None,
195 tags: vec!["auth".into(), "session".into(), "expiry".into()],
196 category: PluginCategory::Auth,
197 compatibility: ">=0.1.0".into(),
198 },
199 PluginMetadata {
200 name: "jwt".into(),
201 version: "0.1.0".into(),
202 description: "JWT token issuance and verification".into(),
203 author: "pylon".into(),
204 license: "MIT".into(),
205 homepage: None,
206 repository: None,
207 tags: vec!["auth".into(), "jwt".into(), "token".into()],
208 category: PluginCategory::Auth,
209 compatibility: ">=0.1.0".into(),
210 },
211 PluginMetadata {
212 name: "totp".into(),
213 version: "0.1.0".into(),
214 description: "TOTP 2FA (RFC 6238)".into(),
215 author: "pylon".into(),
216 license: "MIT".into(),
217 homepage: None,
218 repository: None,
219 tags: vec!["auth".into(), "2fa".into(), "totp".into(), "mfa".into()],
220 category: PluginCategory::Auth,
221 compatibility: ">=0.1.0".into(),
222 },
223 PluginMetadata {
224 name: "organizations".into(),
225 version: "0.1.0".into(),
226 description: "Multi-tenant team management".into(),
227 author: "pylon".into(),
228 license: "MIT".into(),
229 homepage: None,
230 repository: None,
231 tags: vec!["auth".into(), "multi-tenant".into(), "teams".into()],
232 category: PluginCategory::Auth,
233 compatibility: ">=0.1.0".into(),
234 },
235 PluginMetadata {
236 name: "cors".into(),
237 version: "0.1.0".into(),
238 description: "CORS origin validation".into(),
239 author: "pylon".into(),
240 license: "MIT".into(),
241 homepage: None,
242 repository: None,
243 tags: vec!["auth".into(), "cors".into(), "security".into()],
244 category: PluginCategory::Auth,
245 compatibility: ">=0.1.0".into(),
246 },
247 PluginMetadata {
248 name: "csrf".into(),
249 version: "0.1.0".into(),
250 description: "CSRF protection middleware".into(),
251 author: "pylon".into(),
252 license: "MIT".into(),
253 homepage: None,
254 repository: None,
255 tags: vec!["auth".into(), "csrf".into(), "security".into()],
256 category: PluginCategory::Auth,
257 compatibility: ">=0.1.0".into(),
258 },
259 PluginMetadata {
261 name: "file-storage".into(),
262 version: "0.1.0".into(),
263 description: "File upload/download with storage backends".into(),
264 author: "pylon".into(),
265 license: "MIT".into(),
266 homepage: None,
267 repository: None,
268 tags: vec!["storage".into(), "files".into(), "upload".into()],
269 category: PluginCategory::Storage,
270 compatibility: ">=0.1.0".into(),
271 },
272 PluginMetadata {
273 name: "soft-delete".into(),
274 version: "0.1.0".into(),
275 description: "Mark-as-deleted instead of hard delete".into(),
276 author: "pylon".into(),
277 license: "MIT".into(),
278 homepage: None,
279 repository: None,
280 tags: vec!["storage".into(), "soft-delete".into(), "archive".into()],
281 category: PluginCategory::Storage,
282 compatibility: ">=0.1.0".into(),
283 },
284 PluginMetadata {
285 name: "versioning".into(),
286 version: "0.1.0".into(),
287 description: "Row version tracking for optimistic concurrency".into(),
288 author: "pylon".into(),
289 license: "MIT".into(),
290 homepage: None,
291 repository: None,
292 tags: vec!["storage".into(), "versioning".into(), "concurrency".into()],
293 category: PluginCategory::Storage,
294 compatibility: ">=0.1.0".into(),
295 },
296 PluginMetadata {
297 name: "cascade".into(),
298 version: "0.1.0".into(),
299 description: "Cascading deletes across related entities".into(),
300 author: "pylon".into(),
301 license: "MIT".into(),
302 homepage: None,
303 repository: None,
304 tags: vec!["storage".into(), "cascade".into(), "relations".into()],
305 category: PluginCategory::Storage,
306 compatibility: ">=0.1.0".into(),
307 },
308 PluginMetadata {
310 name: "webhooks".into(),
311 version: "0.1.0".into(),
312 description: "Outbound webhook delivery with retries".into(),
313 author: "pylon".into(),
314 license: "MIT".into(),
315 homepage: None,
316 repository: None,
317 tags: vec!["integration".into(), "webhooks".into(), "events".into()],
318 category: PluginCategory::Integration,
319 compatibility: ">=0.1.0".into(),
320 },
321 PluginMetadata {
322 name: "email".into(),
323 version: "0.1.0".into(),
324 description: "Transactional email sending via SMTP/API".into(),
325 author: "pylon".into(),
326 license: "MIT".into(),
327 homepage: None,
328 repository: None,
329 tags: vec!["integration".into(), "email".into(), "smtp".into()],
330 category: PluginCategory::Integration,
331 compatibility: ">=0.1.0".into(),
332 },
333 PluginMetadata {
334 name: "mcp".into(),
335 version: "0.1.0".into(),
336 description: "Model Context Protocol server for AI agents".into(),
337 author: "pylon".into(),
338 license: "MIT".into(),
339 homepage: None,
340 repository: None,
341 tags: vec![
342 "integration".into(),
343 "mcp".into(),
344 "ai".into(),
345 "agents".into(),
346 ],
347 category: PluginCategory::Integration,
348 compatibility: ">=0.1.0".into(),
349 },
350 PluginMetadata {
352 name: "audit-log".into(),
353 version: "0.1.0".into(),
354 description: "Immutable audit trail for all mutations".into(),
355 author: "pylon".into(),
356 license: "MIT".into(),
357 homepage: None,
358 repository: None,
359 tags: vec!["analytics".into(), "audit".into(), "logging".into()],
360 category: PluginCategory::Analytics,
361 compatibility: ">=0.1.0".into(),
362 },
363 PluginMetadata {
364 name: "search".into(),
365 version: "0.1.0".into(),
366 description: "Full-text search across entities".into(),
367 author: "pylon".into(),
368 license: "MIT".into(),
369 homepage: None,
370 repository: None,
371 tags: vec!["analytics".into(), "search".into(), "full-text".into()],
372 category: PluginCategory::Analytics,
373 compatibility: ">=0.1.0".into(),
374 },
375 PluginMetadata {
377 name: "rate-limit".into(),
378 version: "0.1.0".into(),
379 description: "Per-user/IP rate limiting with configurable windows".into(),
380 author: "pylon".into(),
381 license: "MIT".into(),
382 homepage: None,
383 repository: None,
384 tags: vec!["security".into(), "rate-limiting".into()],
385 category: PluginCategory::Security,
386 compatibility: ">=0.1.0".into(),
387 },
388 PluginMetadata {
390 name: "timestamps".into(),
391 version: "0.1.0".into(),
392 description: "Auto-populate created_at and updated_at fields".into(),
393 author: "pylon".into(),
394 license: "MIT".into(),
395 homepage: None,
396 repository: None,
397 tags: vec!["devtools".into(), "timestamps".into(), "auto".into()],
398 category: PluginCategory::DevTools,
399 compatibility: ">=0.1.0".into(),
400 },
401 PluginMetadata {
402 name: "slugify".into(),
403 version: "0.1.0".into(),
404 description: "Auto-generate URL-safe slugs from fields".into(),
405 author: "pylon".into(),
406 license: "MIT".into(),
407 homepage: None,
408 repository: None,
409 tags: vec!["devtools".into(), "slug".into(), "url".into()],
410 category: PluginCategory::DevTools,
411 compatibility: ">=0.1.0".into(),
412 },
413 PluginMetadata {
414 name: "validation".into(),
415 version: "0.1.0".into(),
416 description: "Schema-level field validation rules".into(),
417 author: "pylon".into(),
418 license: "MIT".into(),
419 homepage: None,
420 repository: None,
421 tags: vec!["devtools".into(), "validation".into(), "schema".into()],
422 category: PluginCategory::DevTools,
423 compatibility: ">=0.1.0".into(),
424 },
425 PluginMetadata {
426 name: "computed".into(),
427 version: "0.1.0".into(),
428 description: "Computed/derived fields from other columns".into(),
429 author: "pylon".into(),
430 license: "MIT".into(),
431 homepage: None,
432 repository: None,
433 tags: vec!["devtools".into(), "computed".into(), "derived".into()],
434 category: PluginCategory::DevTools,
435 compatibility: ">=0.1.0".into(),
436 },
437 PluginMetadata {
438 name: "feature-flags".into(),
439 version: "0.1.0".into(),
440 description: "Runtime feature flags with rollout controls".into(),
441 author: "pylon".into(),
442 license: "MIT".into(),
443 homepage: None,
444 repository: None,
445 tags: vec!["devtools".into(), "feature-flags".into(), "rollout".into()],
446 category: PluginCategory::DevTools,
447 compatibility: ">=0.1.0".into(),
448 },
449 PluginMetadata {
451 name: "api-keys".into(),
452 version: "0.1.0".into(),
453 description: "API key generation and authentication".into(),
454 author: "pylon".into(),
455 license: "MIT".into(),
456 homepage: None,
457 repository: None,
458 tags: vec!["api".into(), "keys".into(), "authentication".into()],
459 category: PluginCategory::Other,
460 compatibility: ">=0.1.0".into(),
461 },
462 ];
463
464 for p in builtins {
465 let _ = self.publish(p);
466 }
467 }
468}
469
470impl Default for PluginMarketplace {
471 fn default() -> Self {
472 Self::new()
473 }
474}
475
476#[cfg(test)]
481mod tests {
482 use super::*;
483
484 fn make_plugin(name: &str, category: PluginCategory) -> PluginMetadata {
485 PluginMetadata {
486 name: name.into(),
487 version: "1.0.0".into(),
488 description: format!("A {name} plugin"),
489 author: "test".into(),
490 license: "MIT".into(),
491 homepage: None,
492 repository: None,
493 tags: vec!["test".into(), name.into()],
494 category,
495 compatibility: ">=0.1.0".into(),
496 }
497 }
498
499 #[test]
500 fn publish_and_get() {
501 let mp = PluginMarketplace::new();
502 let plugin = make_plugin("my-plugin", PluginCategory::Auth);
503 assert!(mp.publish(plugin).is_ok());
504 assert_eq!(mp.count(), 1);
505
506 let got = mp.get("my-plugin").unwrap();
507 assert_eq!(got.name, "my-plugin");
508 assert_eq!(got.version, "1.0.0");
509 }
510
511 #[test]
512 fn duplicate_rejected() {
513 let mp = PluginMarketplace::new();
514 let p1 = make_plugin("dup", PluginCategory::Auth);
515 let p2 = make_plugin("dup", PluginCategory::Storage);
516 assert!(mp.publish(p1).is_ok());
517
518 let err = mp.publish(p2).unwrap_err();
519 assert!(err.contains("already published"));
520 }
521
522 #[test]
523 fn empty_name_rejected() {
524 let mp = PluginMarketplace::new();
525 let mut p = make_plugin("x", PluginCategory::Auth);
526 p.name = String::new();
527 let err = mp.publish(p).unwrap_err();
528 assert!(err.contains("name must not be empty"));
529 }
530
531 #[test]
532 fn empty_version_rejected() {
533 let mp = PluginMarketplace::new();
534 let mut p = make_plugin("x", PluginCategory::Auth);
535 p.version = String::new();
536 let err = mp.publish(p).unwrap_err();
537 assert!(err.contains("version must not be empty"));
538 }
539
540 #[test]
541 fn search_by_name() {
542 let mp = PluginMarketplace::new();
543 mp.publish(make_plugin("rate-limiter", PluginCategory::Security))
544 .unwrap();
545 mp.publish(make_plugin("auth-basic", PluginCategory::Auth))
546 .unwrap();
547
548 let results = mp.search("rate");
549 assert_eq!(results.len(), 1);
550 assert_eq!(results[0].name, "rate-limiter");
551 }
552
553 #[test]
554 fn search_by_description() {
555 let mp = PluginMarketplace::new();
556 mp.publish(make_plugin("foo", PluginCategory::Other))
557 .unwrap();
558 let results = mp.search("foo plugin");
560 assert_eq!(results.len(), 1);
561 }
562
563 #[test]
564 fn search_by_tag() {
565 let mp = PluginMarketplace::new();
566 let mut p = make_plugin("widget", PluginCategory::Other);
567 p.tags = vec!["special-tag".into()];
568 mp.publish(p).unwrap();
569
570 let results = mp.search("special-tag");
571 assert_eq!(results.len(), 1);
572 assert_eq!(results[0].name, "widget");
573 }
574
575 #[test]
576 fn search_case_insensitive() {
577 let mp = PluginMarketplace::new();
578 mp.publish(make_plugin("MyPlugin", PluginCategory::Auth))
579 .unwrap();
580
581 assert_eq!(mp.search("myplugin").len(), 1);
582 assert_eq!(mp.search("MYPLUGIN").len(), 1);
583 }
584
585 #[test]
586 fn by_category() {
587 let mp = PluginMarketplace::new();
588 mp.publish(make_plugin("a", PluginCategory::Auth)).unwrap();
589 mp.publish(make_plugin("b", PluginCategory::Auth)).unwrap();
590 mp.publish(make_plugin("c", PluginCategory::Storage))
591 .unwrap();
592
593 let auth = mp.by_category(PluginCategory::Auth);
594 assert_eq!(auth.len(), 2);
595
596 let storage = mp.by_category(PluginCategory::Storage);
597 assert_eq!(storage.len(), 1);
598
599 let billing = mp.by_category(PluginCategory::Billing);
600 assert!(billing.is_empty());
601 }
602
603 #[test]
604 fn unpublish() {
605 let mp = PluginMarketplace::new();
606 mp.publish(make_plugin("rm-me", PluginCategory::Other))
607 .unwrap();
608 assert_eq!(mp.count(), 1);
609
610 assert!(mp.unpublish("rm-me"));
611 assert_eq!(mp.count(), 0);
612 assert!(mp.get("rm-me").is_none());
613 }
614
615 #[test]
616 fn unpublish_nonexistent_returns_false() {
617 let mp = PluginMarketplace::new();
618 assert!(!mp.unpublish("ghost"));
619 }
620
621 #[test]
622 fn list_all() {
623 let mp = PluginMarketplace::new();
624 mp.publish(make_plugin("a", PluginCategory::Auth)).unwrap();
625 mp.publish(make_plugin("b", PluginCategory::Storage))
626 .unwrap();
627
628 let all = mp.list_all();
629 assert_eq!(all.len(), 2);
630 }
631
632 #[test]
633 fn seed_builtins_populates_all() {
634 let mp = PluginMarketplace::new();
635 mp.seed_builtins();
636
637 assert_eq!(mp.count(), 23);
639
640 assert!(mp.get("password-auth").is_some());
642 assert!(mp.get("jwt").is_some());
643 assert!(mp.get("file-storage").is_some());
644 assert!(mp.get("webhooks").is_some());
645 assert!(mp.get("audit-log").is_some());
646 assert!(mp.get("rate-limit").is_some());
647 assert!(mp.get("timestamps").is_some());
648 assert!(mp.get("api-keys").is_some());
649 assert!(mp.get("mcp").is_some());
650 }
651
652 #[test]
653 fn seed_builtins_categories_correct() {
654 let mp = PluginMarketplace::new();
655 mp.seed_builtins();
656
657 assert_eq!(mp.by_category(PluginCategory::Auth).len(), 7);
658 assert_eq!(mp.by_category(PluginCategory::Storage).len(), 4);
659 assert_eq!(mp.by_category(PluginCategory::Integration).len(), 3);
660 assert_eq!(mp.by_category(PluginCategory::Analytics).len(), 2);
661 assert_eq!(mp.by_category(PluginCategory::Security).len(), 1);
662 assert_eq!(mp.by_category(PluginCategory::DevTools).len(), 5);
663 assert_eq!(mp.by_category(PluginCategory::Other).len(), 1);
664 }
665
666 #[test]
667 fn category_as_str() {
668 assert_eq!(PluginCategory::Auth.as_str(), "auth");
669 assert_eq!(PluginCategory::DevTools.as_str(), "devtools");
670 assert_eq!(PluginCategory::Other.as_str(), "other");
671 }
672
673 #[test]
674 fn plugin_metadata_serializes() {
675 let p = make_plugin("test", PluginCategory::Auth);
676 let json = serde_json::to_string(&p).unwrap();
677 let deserialized: PluginMetadata = serde_json::from_str(&json).unwrap();
678 assert_eq!(deserialized.name, "test");
679 assert_eq!(deserialized.category, PluginCategory::Auth);
680 }
681}