Skip to main content

mockforge_plugin_registry/
lib.rs

1//! MockForge Plugin Registry
2//!
3//! Central registry for discovering, publishing, and installing plugins.
4
5pub mod api;
6pub mod config;
7pub mod dependencies;
8pub mod hot_reload;
9pub mod index;
10pub mod manifest;
11pub mod reviews;
12pub mod runtime;
13pub mod security;
14pub mod storage;
15
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use thiserror::Error;
19
20/// Registry errors
21#[derive(Error, Debug)]
22pub enum RegistryError {
23    #[error("Plugin not found: {0}")]
24    PluginNotFound(String),
25
26    #[error("Invalid version: {0}")]
27    InvalidVersion(String),
28
29    #[error("Plugin already exists: {0}")]
30    PluginExists(String),
31
32    #[error("Authentication required")]
33    AuthRequired,
34
35    #[error("Permission denied")]
36    PermissionDenied,
37
38    #[error("Invalid manifest: {0}")]
39    InvalidManifest(String),
40
41    #[error("Storage error: {0}")]
42    Storage(String),
43
44    #[error("Network error: {0}")]
45    Network(String),
46
47    #[error(transparent)]
48    Io(#[from] std::io::Error),
49
50    #[error(transparent)]
51    Serde(#[from] serde_json::Error),
52}
53
54pub type Result<T> = std::result::Result<T, RegistryError>;
55
56/// Plugin registry entry
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct RegistryEntry {
60    /// Plugin name
61    pub name: String,
62
63    /// Plugin description
64    pub description: String,
65
66    /// Current version
67    pub version: String,
68
69    /// All available versions
70    pub versions: Vec<VersionEntry>,
71
72    /// Author information
73    pub author: AuthorInfo,
74
75    /// Plugin tags
76    pub tags: Vec<String>,
77
78    /// Plugin category
79    pub category: PluginCategory,
80
81    /// Download count
82    pub downloads: u64,
83
84    /// Rating (0.0 - 5.0)
85    pub rating: f64,
86
87    /// Total reviews
88    pub reviews_count: u32,
89
90    /// Security score (0-100). Heuristic: verified+maintained plugins score higher.
91    #[serde(default)]
92    pub security_score: u8,
93
94    /// Source language of the plugin (rust/python/javascript/typescript/go/other).
95    #[serde(default)]
96    pub language: String,
97
98    /// Repository URL
99    pub repository: Option<String>,
100
101    /// Homepage URL
102    pub homepage: Option<String>,
103
104    /// License
105    pub license: String,
106
107    /// Created timestamp
108    pub created_at: String,
109
110    /// Updated timestamp
111    pub updated_at: String,
112}
113
114/// Version-specific entry
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct VersionEntry {
118    /// Version string (semver)
119    pub version: String,
120
121    /// Download URL
122    pub download_url: String,
123
124    /// SHA-256 checksum
125    pub checksum: String,
126
127    /// File size in bytes
128    pub size: u64,
129
130    /// Published timestamp
131    pub published_at: String,
132
133    /// Yanked (removed from index)
134    pub yanked: bool,
135
136    /// Minimum MockForge version required
137    pub min_mockforge_version: Option<String>,
138
139    /// Dependencies
140    pub dependencies: HashMap<String, String>,
141
142    /// Per-version download count. Bumped by the
143    /// `/api/v1/plugins/{name}/versions/{version}/download` redirect
144    /// endpoint each time a client follows it.
145    #[serde(default)]
146    pub downloads: u64,
147}
148
149/// Author information
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct AuthorInfo {
152    pub name: String,
153    pub email: Option<String>,
154    pub url: Option<String>,
155}
156
157/// Plugin category
158#[derive(Debug, Clone, Serialize, Deserialize)]
159#[serde(rename_all = "lowercase")]
160pub enum PluginCategory {
161    Auth,
162    Template,
163    Response,
164    DataSource,
165    Middleware,
166    Testing,
167    Observability,
168    Other,
169}
170
171/// Search query
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct SearchQuery {
175    /// Search terms
176    pub query: Option<String>,
177
178    /// Filter by category
179    pub category: Option<PluginCategory>,
180
181    /// Filter by source language (rust/python/javascript/typescript/go/other).
182    #[serde(default)]
183    pub language: Option<String>,
184
185    /// Filter by tags
186    pub tags: Vec<String>,
187
188    /// Sort order
189    pub sort: SortOrder,
190
191    /// Page number (0-indexed)
192    pub page: usize,
193
194    /// Results per page
195    #[serde(alias = "per_page")]
196    pub per_page: usize,
197}
198
199impl Default for SearchQuery {
200    fn default() -> Self {
201        Self {
202            query: None,
203            category: None,
204            language: None,
205            tags: vec![],
206            sort: SortOrder::Relevance,
207            page: 0,
208            per_page: 20,
209        }
210    }
211}
212
213/// Sort order
214#[derive(Debug, Clone, Serialize, Deserialize)]
215#[serde(rename_all = "lowercase")]
216pub enum SortOrder {
217    Relevance,
218    Downloads,
219    Rating,
220    Recent,
221    Name,
222    /// Combined score of downloads + rating — backed by `downloads_total`
223    /// since rating_count alone produces noisy ranking for new plugins.
224    Popular,
225    /// Order by `verified_at IS NOT NULL` then downloads — best proxy for
226    /// the heuristic security score derived in `derive_security_score`.
227    Security,
228}
229
230/// Search results
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct SearchResults {
234    pub plugins: Vec<RegistryEntry>,
235    pub total: usize,
236    pub page: usize,
237    pub per_page: usize,
238}
239
240/// Registry configuration
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct RegistryConfig {
243    /// Registry URL
244    pub url: String,
245
246    /// API token (optional)
247    pub token: Option<String>,
248
249    /// Cache directory
250    pub cache_dir: Option<String>,
251
252    /// Timeout in seconds
253    pub timeout: u64,
254
255    /// Alternative registries
256    pub alternative_registries: Vec<String>,
257}
258
259impl Default for RegistryConfig {
260    fn default() -> Self {
261        Self {
262            url: "https://registry.mockforge.dev".to_string(),
263            token: None,
264            cache_dir: None,
265            timeout: 30,
266            alternative_registries: vec![],
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn create_test_author() -> AuthorInfo {
276        AuthorInfo {
277            name: "Test Author".to_string(),
278            email: Some("test@example.com".to_string()),
279            url: Some("https://example.com".to_string()),
280        }
281    }
282
283    fn create_test_version_entry() -> VersionEntry {
284        VersionEntry {
285            version: "1.0.0".to_string(),
286            download_url: "https://example.com/plugin-1.0.0.tar.gz".to_string(),
287            checksum: "abc123def456".to_string(),
288            size: 12345,
289            published_at: "2025-01-01T00:00:00Z".to_string(),
290            yanked: false,
291            min_mockforge_version: Some("0.3.0".to_string()),
292            dependencies: HashMap::new(),
293            downloads: 0,
294        }
295    }
296
297    fn create_test_registry_entry() -> RegistryEntry {
298        RegistryEntry {
299            name: "test-plugin".to_string(),
300            description: "Test plugin".to_string(),
301            version: "1.0.0".to_string(),
302            versions: vec![create_test_version_entry()],
303            author: create_test_author(),
304            tags: vec!["test".to_string()],
305            category: PluginCategory::Auth,
306            downloads: 100,
307            rating: 4.5,
308            reviews_count: 10,
309            security_score: 0,
310            language: "rust".to_string(),
311            repository: Some("https://github.com/test/plugin".to_string()),
312            homepage: Some("https://plugin.example.com".to_string()),
313            license: "MIT".to_string(),
314            created_at: "2025-01-01T00:00:00Z".to_string(),
315            updated_at: "2025-01-01T00:00:00Z".to_string(),
316        }
317    }
318
319    // RegistryError tests
320    #[test]
321    fn test_registry_error_plugin_not_found() {
322        let error = RegistryError::PluginNotFound("my-plugin".to_string());
323        let display = error.to_string();
324        assert!(display.contains("Plugin not found"));
325        assert!(display.contains("my-plugin"));
326    }
327
328    #[test]
329    fn test_registry_error_invalid_version() {
330        let error = RegistryError::InvalidVersion("bad version".to_string());
331        let display = error.to_string();
332        assert!(display.contains("Invalid version"));
333    }
334
335    #[test]
336    fn test_registry_error_plugin_exists() {
337        let error = RegistryError::PluginExists("existing-plugin".to_string());
338        let display = error.to_string();
339        assert!(display.contains("Plugin already exists"));
340    }
341
342    #[test]
343    fn test_registry_error_auth_required() {
344        let error = RegistryError::AuthRequired;
345        let display = error.to_string();
346        assert!(display.contains("Authentication required"));
347    }
348
349    #[test]
350    fn test_registry_error_permission_denied() {
351        let error = RegistryError::PermissionDenied;
352        let display = error.to_string();
353        assert!(display.contains("Permission denied"));
354    }
355
356    #[test]
357    fn test_registry_error_invalid_manifest() {
358        let error = RegistryError::InvalidManifest("missing field".to_string());
359        let display = error.to_string();
360        assert!(display.contains("Invalid manifest"));
361    }
362
363    #[test]
364    fn test_registry_error_storage() {
365        let error = RegistryError::Storage("disk full".to_string());
366        let display = error.to_string();
367        assert!(display.contains("Storage error"));
368    }
369
370    #[test]
371    fn test_registry_error_network() {
372        let error = RegistryError::Network("connection refused".to_string());
373        let display = error.to_string();
374        assert!(display.contains("Network error"));
375    }
376
377    #[test]
378    fn test_registry_error_from_io() {
379        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
380        let error: RegistryError = io_error.into();
381        assert!(matches!(error, RegistryError::Io(_)));
382    }
383
384    #[test]
385    fn test_registry_error_debug() {
386        let error = RegistryError::AuthRequired;
387        let debug = format!("{:?}", error);
388        assert!(debug.contains("AuthRequired"));
389    }
390
391    // AuthorInfo tests
392    #[test]
393    fn test_author_info_clone() {
394        let author = create_test_author();
395        let cloned = author.clone();
396        assert_eq!(author.name, cloned.name);
397        assert_eq!(author.email, cloned.email);
398        assert_eq!(author.url, cloned.url);
399    }
400
401    #[test]
402    fn test_author_info_debug() {
403        let author = create_test_author();
404        let debug = format!("{:?}", author);
405        assert!(debug.contains("AuthorInfo"));
406        assert!(debug.contains("Test Author"));
407    }
408
409    #[test]
410    fn test_author_info_serialize() {
411        let author = create_test_author();
412        let json = serde_json::to_string(&author).unwrap();
413        assert!(json.contains("\"name\":\"Test Author\""));
414        assert!(json.contains("\"email\":\"test@example.com\""));
415    }
416
417    #[test]
418    fn test_author_info_deserialize() {
419        let json = r#"{"name":"Author","email":null,"url":null}"#;
420        let author: AuthorInfo = serde_json::from_str(json).unwrap();
421        assert_eq!(author.name, "Author");
422        assert!(author.email.is_none());
423    }
424
425    // PluginCategory tests
426    #[test]
427    fn test_plugin_category_serialize_all() {
428        assert_eq!(serde_json::to_string(&PluginCategory::Auth).unwrap(), "\"auth\"");
429        assert_eq!(serde_json::to_string(&PluginCategory::Template).unwrap(), "\"template\"");
430        assert_eq!(serde_json::to_string(&PluginCategory::Response).unwrap(), "\"response\"");
431        assert_eq!(serde_json::to_string(&PluginCategory::DataSource).unwrap(), "\"datasource\"");
432        assert_eq!(serde_json::to_string(&PluginCategory::Middleware).unwrap(), "\"middleware\"");
433        assert_eq!(serde_json::to_string(&PluginCategory::Testing).unwrap(), "\"testing\"");
434        assert_eq!(
435            serde_json::to_string(&PluginCategory::Observability).unwrap(),
436            "\"observability\""
437        );
438        assert_eq!(serde_json::to_string(&PluginCategory::Other).unwrap(), "\"other\"");
439    }
440
441    #[test]
442    fn test_plugin_category_deserialize() {
443        let category: PluginCategory = serde_json::from_str("\"middleware\"").unwrap();
444        assert!(matches!(category, PluginCategory::Middleware));
445    }
446
447    #[test]
448    fn test_plugin_category_clone() {
449        let category = PluginCategory::Testing;
450        let cloned = category.clone();
451        assert!(matches!(cloned, PluginCategory::Testing));
452    }
453
454    #[test]
455    fn test_plugin_category_debug() {
456        let category = PluginCategory::Observability;
457        let debug = format!("{:?}", category);
458        assert!(debug.contains("Observability"));
459    }
460
461    // SortOrder tests
462    #[test]
463    fn test_sort_order_serialize_all() {
464        assert_eq!(serde_json::to_string(&SortOrder::Relevance).unwrap(), "\"relevance\"");
465        assert_eq!(serde_json::to_string(&SortOrder::Downloads).unwrap(), "\"downloads\"");
466        assert_eq!(serde_json::to_string(&SortOrder::Rating).unwrap(), "\"rating\"");
467        assert_eq!(serde_json::to_string(&SortOrder::Recent).unwrap(), "\"recent\"");
468        assert_eq!(serde_json::to_string(&SortOrder::Name).unwrap(), "\"name\"");
469        assert_eq!(serde_json::to_string(&SortOrder::Popular).unwrap(), "\"popular\"");
470        assert_eq!(serde_json::to_string(&SortOrder::Security).unwrap(), "\"security\"");
471    }
472
473    #[test]
474    fn test_sort_order_deserialize() {
475        let sort: SortOrder = serde_json::from_str("\"downloads\"").unwrap();
476        assert!(matches!(sort, SortOrder::Downloads));
477    }
478
479    #[test]
480    fn test_sort_order_clone() {
481        let sort = SortOrder::Rating;
482        let cloned = sort.clone();
483        assert!(matches!(cloned, SortOrder::Rating));
484    }
485
486    #[test]
487    fn test_sort_order_debug() {
488        let sort = SortOrder::Recent;
489        let debug = format!("{:?}", sort);
490        assert!(debug.contains("Recent"));
491    }
492
493    // VersionEntry tests
494    #[test]
495    fn test_version_entry_clone() {
496        let entry = create_test_version_entry();
497        let cloned = entry.clone();
498        assert_eq!(entry.version, cloned.version);
499        assert_eq!(entry.checksum, cloned.checksum);
500    }
501
502    #[test]
503    fn test_version_entry_debug() {
504        let entry = create_test_version_entry();
505        let debug = format!("{:?}", entry);
506        assert!(debug.contains("VersionEntry"));
507        assert!(debug.contains("1.0.0"));
508    }
509
510    #[test]
511    fn test_version_entry_serialize() {
512        let entry = create_test_version_entry();
513        let json = serde_json::to_string(&entry).unwrap();
514        assert!(json.contains("\"version\":\"1.0.0\""));
515        assert!(json.contains("\"yanked\":false"));
516    }
517
518    #[test]
519    fn test_version_entry_with_dependencies() {
520        let mut entry = create_test_version_entry();
521        entry.dependencies.insert("other-plugin".to_string(), "^1.0".to_string());
522
523        let json = serde_json::to_string(&entry).unwrap();
524        assert!(json.contains("other-plugin"));
525    }
526
527    #[test]
528    fn test_version_entry_yanked() {
529        let mut entry = create_test_version_entry();
530        entry.yanked = true;
531
532        let json = serde_json::to_string(&entry).unwrap();
533        assert!(json.contains("\"yanked\":true"));
534    }
535
536    // RegistryEntry tests
537    #[test]
538    fn test_registry_entry_serialization() {
539        let entry = create_test_registry_entry();
540        let json = serde_json::to_string(&entry).unwrap();
541        let deserialized: RegistryEntry = serde_json::from_str(&json).unwrap();
542        assert_eq!(entry.name, deserialized.name);
543        assert_eq!(entry.version, deserialized.version);
544        assert_eq!(entry.downloads, deserialized.downloads);
545    }
546
547    #[test]
548    fn test_registry_entry_clone() {
549        let entry = create_test_registry_entry();
550        let cloned = entry.clone();
551        assert_eq!(entry.name, cloned.name);
552        assert_eq!(entry.rating, cloned.rating);
553    }
554
555    #[test]
556    fn test_registry_entry_debug() {
557        let entry = create_test_registry_entry();
558        let debug = format!("{:?}", entry);
559        assert!(debug.contains("RegistryEntry"));
560        assert!(debug.contains("test-plugin"));
561    }
562
563    #[test]
564    fn test_registry_entry_with_no_optional_fields() {
565        let entry = RegistryEntry {
566            name: "minimal".to_string(),
567            description: "Minimal plugin".to_string(),
568            version: "0.1.0".to_string(),
569            versions: vec![],
570            author: AuthorInfo {
571                name: "Author".to_string(),
572                email: None,
573                url: None,
574            },
575            tags: vec![],
576            category: PluginCategory::Other,
577            downloads: 0,
578            rating: 0.0,
579            reviews_count: 0,
580            security_score: 0,
581            language: "rust".to_string(),
582            repository: None,
583            homepage: None,
584            license: "MIT".to_string(),
585            created_at: "2025-01-01".to_string(),
586            updated_at: "2025-01-01".to_string(),
587        };
588
589        let json = serde_json::to_string(&entry).unwrap();
590        let deserialized: RegistryEntry = serde_json::from_str(&json).unwrap();
591        assert!(deserialized.repository.is_none());
592    }
593
594    // SearchQuery tests
595    #[test]
596    fn test_search_query_default() {
597        let query = SearchQuery::default();
598        assert_eq!(query.page, 0);
599        assert_eq!(query.per_page, 20);
600        assert!(query.query.is_none());
601        assert!(query.category.is_none());
602        assert!(query.tags.is_empty());
603        assert!(matches!(query.sort, SortOrder::Relevance));
604    }
605
606    #[test]
607    fn test_search_query_clone() {
608        let query = SearchQuery {
609            query: Some("auth".to_string()),
610            page: 5,
611            ..Default::default()
612        };
613
614        let cloned = query.clone();
615        assert_eq!(query.query, cloned.query);
616        assert_eq!(query.page, cloned.page);
617    }
618
619    #[test]
620    fn test_search_query_serialize() {
621        let query = SearchQuery {
622            query: Some("jwt".to_string()),
623            category: Some(PluginCategory::Auth),
624            language: None,
625            tags: vec!["security".to_string()],
626            sort: SortOrder::Downloads,
627            page: 1,
628            per_page: 50,
629        };
630
631        let json = serde_json::to_string(&query).unwrap();
632        assert!(json.contains("\"query\":\"jwt\""));
633        assert!(json.contains("\"category\":\"auth\""));
634    }
635
636    #[test]
637    fn test_search_query_debug() {
638        let query = SearchQuery::default();
639        let debug = format!("{:?}", query);
640        assert!(debug.contains("SearchQuery"));
641    }
642
643    // SearchResults tests
644    #[test]
645    fn test_search_results_serialize() {
646        let results = SearchResults {
647            plugins: vec![create_test_registry_entry()],
648            total: 1,
649            page: 0,
650            per_page: 20,
651        };
652
653        let json = serde_json::to_string(&results).unwrap();
654        assert!(json.contains("\"total\":1"));
655        assert!(json.contains("test-plugin"));
656    }
657
658    #[test]
659    fn test_search_results_clone() {
660        let results = SearchResults {
661            plugins: vec![],
662            total: 100,
663            page: 5,
664            per_page: 20,
665        };
666
667        let cloned = results.clone();
668        assert_eq!(results.total, cloned.total);
669        assert_eq!(results.page, cloned.page);
670    }
671
672    #[test]
673    fn test_search_results_debug() {
674        let results = SearchResults {
675            plugins: vec![],
676            total: 0,
677            page: 0,
678            per_page: 20,
679        };
680
681        let debug = format!("{:?}", results);
682        assert!(debug.contains("SearchResults"));
683    }
684
685    #[test]
686    fn test_search_results_empty() {
687        let results = SearchResults {
688            plugins: vec![],
689            total: 0,
690            page: 0,
691            per_page: 20,
692        };
693
694        let json = serde_json::to_string(&results).unwrap();
695        let deserialized: SearchResults = serde_json::from_str(&json).unwrap();
696        assert!(deserialized.plugins.is_empty());
697        assert_eq!(deserialized.total, 0);
698    }
699
700    // RegistryConfig tests
701    #[test]
702    fn test_registry_config_default() {
703        let config = RegistryConfig::default();
704        assert_eq!(config.url, "https://registry.mockforge.dev");
705        assert!(config.token.is_none());
706        assert!(config.cache_dir.is_none());
707        assert_eq!(config.timeout, 30);
708        assert!(config.alternative_registries.is_empty());
709    }
710
711    #[test]
712    fn test_registry_config_clone() {
713        let config = RegistryConfig {
714            token: Some("secret-token".to_string()),
715            ..Default::default()
716        };
717
718        let cloned = config.clone();
719        assert_eq!(config.url, cloned.url);
720        assert_eq!(config.token, cloned.token);
721    }
722
723    #[test]
724    fn test_registry_config_serialize() {
725        let config = RegistryConfig::default();
726        let json = serde_json::to_string(&config).unwrap();
727        assert!(json.contains("\"url\":\"https://registry.mockforge.dev\""));
728        assert!(json.contains("\"timeout\":30"));
729    }
730
731    #[test]
732    fn test_registry_config_deserialize() {
733        let json = r#"{
734            "url": "https://custom.registry.com",
735            "token": "my-token",
736            "cache_dir": "/tmp/cache",
737            "timeout": 60,
738            "alternative_registries": ["https://alt.registry.com"]
739        }"#;
740
741        let config: RegistryConfig = serde_json::from_str(json).unwrap();
742        assert_eq!(config.url, "https://custom.registry.com");
743        assert_eq!(config.token, Some("my-token".to_string()));
744        assert_eq!(config.cache_dir, Some("/tmp/cache".to_string()));
745        assert_eq!(config.timeout, 60);
746        assert_eq!(config.alternative_registries.len(), 1);
747    }
748
749    #[test]
750    fn test_registry_config_debug() {
751        let config = RegistryConfig::default();
752        let debug = format!("{:?}", config);
753        assert!(debug.contains("RegistryConfig"));
754    }
755
756    #[test]
757    fn test_registry_config_with_alternatives() {
758        let config = RegistryConfig {
759            alternative_registries: vec![
760                "https://mirror1.registry.com".to_string(),
761                "https://mirror2.registry.com".to_string(),
762            ],
763            ..Default::default()
764        };
765
766        let json = serde_json::to_string(&config).unwrap();
767        assert!(json.contains("mirror1"));
768        assert!(json.contains("mirror2"));
769    }
770}