Skip to main content

roboticus_plugin_sdk/
catalog.rs

1//! Plugin catalog types for remote plugin discovery and distribution.
2//!
3//! These types map the `plugins` section of the Roboticus registry manifest.
4
5use serde::{Deserialize, Serialize};
6
7/// A single plugin entry in the remote catalog.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct PluginCatalogEntry {
10    /// Plugin name (must match the manifest's `name` field).
11    pub name: String,
12    /// Semver version string.
13    pub version: String,
14    /// Human-readable description.
15    pub description: String,
16    /// Author or organization.
17    #[serde(default)]
18    pub author: String,
19    /// SHA-256 hex digest of the `.ic.zip` archive.
20    pub sha256: String,
21    /// Relative path to the archive within the registry (e.g., `plugins/claude-code-0.1.0.ic.zip`).
22    pub path: String,
23    /// Minimum Roboticus version required to run this plugin.
24    #[serde(default)]
25    pub min_version: Option<String>,
26    /// Trust tier: "official", "community", "third-party".
27    #[serde(default = "default_tier")]
28    pub tier: String,
29}
30
31fn default_tier() -> String {
32    "community".to_string()
33}
34
35/// The `plugins` section of the registry manifest.
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37pub struct PluginCatalog {
38    /// Available plugins for installation.
39    #[serde(default)]
40    pub catalog: Vec<PluginCatalogEntry>,
41}
42
43impl PluginCatalog {
44    /// Search catalog entries by name or description substring (case-insensitive).
45    pub fn search(&self, query: &str) -> Vec<&PluginCatalogEntry> {
46        let q = query.to_lowercase();
47        self.catalog
48            .iter()
49            .filter(|e| {
50                e.name.to_lowercase().contains(&q)
51                    || e.description.to_lowercase().contains(&q)
52                    || e.author.to_lowercase().contains(&q)
53            })
54            .collect()
55    }
56
57    /// Find a specific plugin by exact name.
58    pub fn find(&self, name: &str) -> Option<&PluginCatalogEntry> {
59        self.catalog.iter().find(|e| e.name == name)
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    fn sample_catalog() -> PluginCatalog {
68        PluginCatalog {
69            catalog: vec![
70                PluginCatalogEntry {
71                    name: "claude-code".into(),
72                    version: "0.1.0".into(),
73                    description: "Delegate coding tasks to Claude Code CLI".into(),
74                    author: "Roboticus".into(),
75                    sha256: "abc123".into(),
76                    path: "plugins/claude-code-0.1.0.ic.zip".into(),
77                    min_version: Some("0.9.4".into()),
78                    tier: "official".into(),
79                },
80                PluginCatalogEntry {
81                    name: "weather".into(),
82                    version: "1.0.0".into(),
83                    description: "Check weather forecasts".into(),
84                    author: "Community".into(),
85                    sha256: "def456".into(),
86                    path: "plugins/weather-1.0.0.ic.zip".into(),
87                    min_version: None,
88                    tier: "community".into(),
89                },
90            ],
91        }
92    }
93
94    #[test]
95    fn search_by_name() {
96        let cat = sample_catalog();
97        let results = cat.search("claude");
98        assert_eq!(results.len(), 1);
99        assert_eq!(results[0].name, "claude-code");
100    }
101
102    #[test]
103    fn search_by_description() {
104        let cat = sample_catalog();
105        let results = cat.search("forecast");
106        assert_eq!(results.len(), 1);
107        assert_eq!(results[0].name, "weather");
108    }
109
110    #[test]
111    fn search_case_insensitive() {
112        let cat = sample_catalog();
113        let results = cat.search("CLAUDE");
114        assert_eq!(results.len(), 1);
115    }
116
117    #[test]
118    fn find_exact() {
119        let cat = sample_catalog();
120        assert!(cat.find("weather").is_some());
121        assert!(cat.find("nonexistent").is_none());
122    }
123
124    #[test]
125    fn empty_search_returns_all() {
126        let cat = sample_catalog();
127        let results = cat.search("");
128        assert_eq!(results.len(), 2);
129    }
130
131    #[test]
132    fn serde_roundtrip() {
133        let cat = sample_catalog();
134        let json = serde_json::to_string(&cat).unwrap();
135        let back: PluginCatalog = serde_json::from_str(&json).unwrap();
136        assert_eq!(back.catalog.len(), 2);
137        assert_eq!(back.catalog[0].tier, "official");
138    }
139}