Skip to main content

pylon_plugin/
registry.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::sync::Mutex;
4
5// ---------------------------------------------------------------------------
6// Plugin marketplace metadata types
7// ---------------------------------------------------------------------------
8
9/// Metadata for a published plugin in the marketplace.
10#[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    /// Semver range for pylon version compatibility.
22    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    /// Display-friendly label for CLI output.
55    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    /// Ordered list of all categories for consistent display.
70    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
85// ---------------------------------------------------------------------------
86// Plugin marketplace — in-memory registry
87// ---------------------------------------------------------------------------
88
89/// A local plugin marketplace registry.
90///
91/// In production this would talk to a remote API. For now it is an in-memory
92/// catalog used for testing, development, and the CLI `plugins` command.
93pub 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    /// Register a plugin in the marketplace.
105    ///
106    /// Returns `Err` if the name is empty, version is empty, or a plugin with
107    /// the same name is already published.
108    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    /// Search plugins by query string.
125    ///
126    /// Matches against name, description, and tags (case-insensitive).
127    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    /// List plugins by category.
142    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    /// Get a specific plugin by name.
152    pub fn get(&self, name: &str) -> Option<PluginMetadata> {
153        self.plugins.lock().unwrap().get(name).cloned()
154    }
155
156    /// List all available plugins.
157    pub fn list_all(&self) -> Vec<PluginMetadata> {
158        self.plugins.lock().unwrap().values().cloned().collect()
159    }
160
161    /// Remove a plugin from the marketplace. Returns `true` if it existed.
162    pub fn unpublish(&self, name: &str) -> bool {
163        self.plugins.lock().unwrap().remove(name).is_some()
164    }
165
166    /// Get plugin count.
167    pub fn count(&self) -> usize {
168        self.plugins.lock().unwrap().len()
169    }
170
171    /// Seed the marketplace with all built-in plugin metadata.
172    pub fn seed_builtins(&self) {
173        let builtins = vec![
174            // -- Auth --
175            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            // -- Storage --
260            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            // -- Integration --
309            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            // -- Analytics --
351            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            // -- Security --
376            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            // -- DevTools --
389            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            // -- Other --
450            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// ---------------------------------------------------------------------------
477// Tests
478// ---------------------------------------------------------------------------
479
480#[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        // description is "A foo plugin"
559        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        // 23 built-in plugins total
638        assert_eq!(mp.count(), 23);
639
640        // Spot-check a few from different categories
641        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}