Skip to main content

telos_agent/integrations/plugin/
marketplace.rs

1//! Marketplace registry — manages marketplace sources and their plugin catalogs.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::integrations::plugin::PluginError;
7use crate::integrations::plugin::manifest::{MarketplaceEntry, PluginAuthor};
8use crate::integrations::plugin::sources::MarketplaceSource;
9
10/// A curated collection of plugins fetched from a marketplace source.
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct Marketplace {
14    pub name: String,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub owner: Option<PluginAuthor>,
17    pub plugins: Vec<MarketplaceEntry>,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub force_remove_deleted_plugins: Option<bool>,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub allow_cross_marketplace_deps_on: Option<Vec<String>>,
22}
23
24/// Cached marketplace data stored on disk.
25#[derive(Debug, Clone)]
26struct CachedMarketplace {
27    source: MarketplaceSource,
28    manifest: Marketplace,
29    /// Where the marketplace is cached on disk.
30    install_location: PathBuf,
31    /// When the marketplace was last refreshed (unix timestamp seconds).
32    last_updated: u64,
33}
34
35/// Manages marketplace sources and provides plugin discovery across them.
36pub struct MarketplaceRegistry {
37    marketplaces: HashMap<String, CachedMarketplace>,
38    cache_root: PathBuf,
39}
40
41impl MarketplaceRegistry {
42    /// Create a new marketplace registry. Cache goes under `cache_root/marketplaces/`.
43    pub fn new(cache_root: impl Into<PathBuf>) -> Self {
44        Self { marketplaces: HashMap::new(), cache_root: cache_root.into() }
45    }
46
47    /// Add a marketplace source. For local/inline sources, this is immediate.
48    /// For remote sources (GitHub, git, URL, npm), fetching happens in
49    /// `refresh()`.
50    ///
51    /// Returns the marketplace name.
52    pub fn add(&mut self, source: MarketplaceSource) -> Result<String, PluginError> {
53        let name = match &source {
54            MarketplaceSource::GitHub { repo, .. } => {
55                // Derive name from repo: strip org, keep repo name
56                repo.split('/').next_back().unwrap_or(repo).to_string()
57            }
58            MarketplaceSource::Git { url, .. } => {
59                // Derive name from URL: last path segment without .git
60                url.trim_end_matches('/')
61                    .trim_end_matches(".git")
62                    .split('/')
63                    .next_back()
64                    .unwrap_or("unknown")
65                    .to_string()
66            }
67            MarketplaceSource::Url { url } => {
68                url.trim_end_matches('/').split('/').next_back().unwrap_or("unknown").to_string()
69            }
70            MarketplaceSource::Npm { package } => package.replace('/', "-"),
71            MarketplaceSource::Local { path } => {
72                path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown").to_string()
73            }
74            MarketplaceSource::Inline { name, .. } => name.clone(),
75        };
76
77        let install_location = self.cache_root.join("marketplaces").join(&name);
78
79        // For local and inline sources, load immediately
80        let (manifest, last_updated) = match &source {
81            MarketplaceSource::Local { path } => {
82                let manifest = Self::load_manifest_from_dir(path)?;
83                let timestamp = std::time::SystemTime::now()
84                    .duration_since(std::time::UNIX_EPOCH)
85                    .unwrap_or_default()
86                    .as_secs();
87                (manifest, timestamp)
88            }
89            MarketplaceSource::Inline { name, plugins } => {
90                let manifest = Marketplace {
91                    name: name.clone(),
92                    owner: None,
93                    plugins: plugins.clone(),
94                    force_remove_deleted_plugins: None,
95                    allow_cross_marketplace_deps_on: None,
96                };
97                (manifest, 0)
98            }
99            _ => {
100                // Remote sources: create a placeholder; refresh() fills it in
101                let manifest = Marketplace {
102                    name: name.clone(),
103                    owner: None,
104                    plugins: Vec::new(),
105                    force_remove_deleted_plugins: None,
106                    allow_cross_marketplace_deps_on: None,
107                };
108                (manifest, 0)
109            }
110        };
111
112        self.marketplaces.insert(
113            name.clone(),
114            CachedMarketplace { source, manifest, install_location, last_updated },
115        );
116
117        Ok(name)
118    }
119
120    /// Remove a marketplace and its cached data.
121    pub fn remove(&mut self, name: &str) -> Result<(), PluginError> {
122        self.marketplaces.remove(name).ok_or_else(|| PluginError::MarketplaceNotFound {
123            marketplace: name.to_string(),
124            available: self.marketplaces.keys().cloned().collect(),
125        })?;
126        Ok(())
127    }
128
129    /// Get the marketplace manifest by name.
130    pub fn get(&self, name: &str) -> Option<&Marketplace> {
131        self.marketplaces.get(name).map(|c| &c.manifest)
132    }
133
134    /// List all registered marketplace names.
135    pub fn names(&self) -> Vec<&String> {
136        self.marketplaces.keys().collect()
137    }
138
139    /// Search across all marketplaces for plugins matching `query`
140    /// (case-insensitive substring match on name and description).
141    pub fn search(&self, query: &str) -> Vec<(&Marketplace, &MarketplaceEntry)> {
142        let query = query.to_lowercase();
143        let mut results = Vec::new();
144        for cached in self.marketplaces.values() {
145            for entry in &cached.manifest.plugins {
146                if entry.name.to_lowercase().contains(&query)
147                    || entry.description.as_ref().is_some_and(|d| d.to_lowercase().contains(&query))
148                {
149                    results.push((&cached.manifest, entry));
150                }
151            }
152        }
153        results
154    }
155
156    /// List all available plugins across all marketplaces.
157    pub fn list_all(&self) -> Vec<(&Marketplace, &MarketplaceEntry)> {
158        let mut results = Vec::new();
159        for cached in self.marketplaces.values() {
160            for entry in &cached.manifest.plugins {
161                results.push((&cached.manifest, entry));
162            }
163        }
164        results
165    }
166
167    /// Save known marketplaces metadata to `known_marketplaces.json`.
168    pub fn save(&self) -> Result<(), PluginError> {
169        let path = self.cache_root.join("known_marketplaces.json");
170        if let Some(parent) = path.parent() {
171            std::fs::create_dir_all(parent)?;
172        }
173        let data: HashMap<String, serde_json::Value> = self
174            .marketplaces
175            .iter()
176            .map(|(name, cached)| {
177                let entry = serde_json::json!({
178                    "source": cached.source,
179                    "installLocation": cached.install_location,
180                    "lastUpdated": cached.last_updated,
181                });
182                (name.clone(), entry)
183            })
184            .collect();
185        let json = serde_json::to_string_pretty(&serde_json::json!({
186            "version": 1,
187            "marketplaces": data,
188        }))?;
189        std::fs::write(&path, json)?;
190        Ok(())
191    }
192
193    /// Load known marketplaces from `known_marketplaces.json`.
194    pub fn load(&mut self) -> Result<(), PluginError> {
195        let path = self.cache_root.join("known_marketplaces.json");
196        if !path.exists() {
197            return Ok(());
198        }
199        let content = std::fs::read_to_string(&path)?;
200        let value: serde_json::Value = serde_json::from_str(&content)?;
201        if let Some(marketplaces) = value.get("marketplaces").and_then(|v| v.as_object()) {
202            for (name, entry) in marketplaces {
203                if self.marketplaces.contains_key(name) {
204                    continue; // already registered, skip
205                }
206                let source: MarketplaceSource = match serde_json::from_value(
207                    entry.get("source").cloned().unwrap_or_default(),
208                ) {
209                    Ok(s) => s,
210                    Err(_) => continue,
211                };
212                let install_location = entry
213                    .get("installLocation")
214                    .and_then(|v| v.as_str())
215                    .map(PathBuf::from)
216                    .unwrap_or_else(|| self.cache_root.join("marketplaces").join(name));
217                let last_updated = entry.get("lastUpdated").and_then(|v| v.as_u64()).unwrap_or(0);
218
219                // For disk-backed sources, try to load the manifest
220                let manifest = match &source {
221                    MarketplaceSource::Local { path } => Self::load_manifest_from_dir(path)
222                        .unwrap_or_else(|_| Marketplace {
223                            name: name.clone(),
224                            owner: None,
225                            plugins: Vec::new(),
226                            force_remove_deleted_plugins: None,
227                            allow_cross_marketplace_deps_on: None,
228                        }),
229                    MarketplaceSource::Inline { name: inline_name, plugins } => Marketplace {
230                        name: inline_name.clone(),
231                        owner: None,
232                        plugins: plugins.clone(),
233                        force_remove_deleted_plugins: None,
234                        allow_cross_marketplace_deps_on: None,
235                    },
236                    _ => Marketplace {
237                        name: name.clone(),
238                        owner: None,
239                        plugins: Vec::new(),
240                        force_remove_deleted_plugins: None,
241                        allow_cross_marketplace_deps_on: None,
242                    },
243                };
244
245                self.marketplaces.insert(
246                    name.clone(),
247                    CachedMarketplace { source, manifest, install_location, last_updated },
248                );
249            }
250        }
251        Ok(())
252    }
253
254    /// Load a marketplace manifest from a directory containing marketplace.json.
255    fn load_manifest_from_dir(dir: &Path) -> Result<Marketplace, PluginError> {
256        let manifest_path = dir.join("marketplace.json");
257        let content =
258            std::fs::read_to_string(&manifest_path).map_err(|e| PluginError::ManifestParse {
259                path: manifest_path.clone(),
260                reason: format!("failed to read: {e}"),
261            })?;
262        let manifest: Marketplace = serde_json::from_str(&content).map_err(|e| {
263            PluginError::ManifestParse { path: manifest_path, reason: format!("invalid JSON: {e}") }
264        })?;
265        Ok(manifest)
266    }
267
268    /// Refresh a marketplace by re-reading its source.
269    ///
270    /// For `Local` sources, this re-reads marketplace.json from disk.
271    /// For remote sources (GitHub, Git, Url, Npm), this is not yet implemented.
272    /// For `Inline` sources, this is a no-op.
273    pub fn refresh(&mut self, name: &str) -> Result<(), PluginError> {
274        let cached =
275            self.marketplaces.get(name).ok_or_else(|| PluginError::MarketplaceNotFound {
276                marketplace: name.to_string(),
277                available: self.marketplaces.keys().cloned().collect(),
278            })?;
279
280        let source = cached.source.clone();
281        match &source {
282            MarketplaceSource::Local { path } => {
283                let manifest = Self::load_manifest_from_dir(path)?;
284                let now = std::time::SystemTime::now()
285                    .duration_since(std::time::UNIX_EPOCH)
286                    .unwrap_or_default()
287                    .as_secs();
288                if let Some(cached) = self.marketplaces.get_mut(name) {
289                    cached.manifest = manifest;
290                    cached.last_updated = now;
291                }
292                Ok(())
293            }
294            MarketplaceSource::Inline { .. } => {
295                // Inline marketplaces are immutable; nothing to refresh.
296                Ok(())
297            }
298            _ => Err(PluginError::Other("remote marketplace refresh not yet implemented".into())),
299        }
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use tempfile::TempDir;
307
308    fn make_marketplace_dir(dir: &Path, name: &str) {
309        std::fs::create_dir_all(dir).unwrap();
310        let manifest = serde_json::json!({
311            "name": name,
312            "owner": {"name": "Test Org"},
313            "plugins": [
314                {
315                    "name": "test-plugin",
316                    "description": "A test plugin",
317                    "source": {"type": "local", "path": "./test-plugin"},
318                    "category": "testing",
319                    "tags": ["test"]
320                },
321                {
322                    "name": "another-plugin",
323                    "description": "Another one",
324                    "source": {"type": "github", "repo": "org/repo"}
325                }
326            ]
327        });
328        std::fs::write(
329            dir.join("marketplace.json"),
330            serde_json::to_string_pretty(&manifest).unwrap(),
331        )
332        .unwrap();
333    }
334
335    #[test]
336    fn add_local_marketplace() {
337        let tmp = TempDir::new().unwrap();
338        let mkt_dir = tmp.path().join("my-marketplace");
339        make_marketplace_dir(&mkt_dir, "my-marketplace");
340
341        let mut registry = MarketplaceRegistry::new(tmp.path());
342        let name = registry.add(MarketplaceSource::Local { path: mkt_dir }).unwrap();
343        assert_eq!(name, "my-marketplace");
344
345        let mkt = registry.get("my-marketplace").unwrap();
346        assert_eq!(mkt.plugins.len(), 2);
347        assert_eq!(mkt.plugins[0].name, "test-plugin");
348    }
349
350    #[test]
351    fn add_inline_marketplace() {
352        let tmp = TempDir::new().unwrap();
353        let mut registry = MarketplaceRegistry::new(tmp.path());
354        let name = registry
355            .add(MarketplaceSource::Inline {
356                name: "inline-mkt".into(),
357                plugins: vec![MarketplaceEntry {
358                    name: "my-plugin".into(),
359                    description: Some("desc".into()),
360                    version: None,
361                    source: crate::integrations::plugin::manifest::PluginSource::Local {
362                        path: "/tmp/p".into(),
363                    },
364                    category: None,
365                    tags: vec![],
366                    strict: true,
367                    manifest_override: None,
368                }],
369            })
370            .unwrap();
371        assert_eq!(name, "inline-mkt");
372        assert_eq!(registry.list_all().len(), 1);
373    }
374
375    #[test]
376    fn search_finds_matching_plugins() {
377        let tmp = TempDir::new().unwrap();
378        let mkt_dir = tmp.path().join("mkt");
379        make_marketplace_dir(&mkt_dir, "mkt");
380
381        let mut registry = MarketplaceRegistry::new(tmp.path());
382        registry.add(MarketplaceSource::Local { path: mkt_dir }).unwrap();
383
384        let results = registry.search("test");
385        assert_eq!(results.len(), 1);
386        assert_eq!(results[0].1.name, "test-plugin");
387
388        let results = registry.search("another");
389        assert_eq!(results.len(), 1);
390        assert_eq!(results[0].1.name, "another-plugin");
391
392        let results = registry.search("nonexistent");
393        assert!(results.is_empty());
394    }
395
396    #[test]
397    fn remove_marketplace() {
398        let tmp = TempDir::new().unwrap();
399        let mut registry = MarketplaceRegistry::new(tmp.path());
400        registry.add(MarketplaceSource::Inline { name: "test".into(), plugins: vec![] }).unwrap();
401        assert!(registry.names().contains(&&"test".to_string()));
402        registry.remove("test").unwrap();
403        assert!(!registry.names().contains(&&"test".to_string()));
404    }
405
406    #[test]
407    fn save_and_load_marketplaces() {
408        let tmp = TempDir::new().unwrap();
409        let mkt_dir = tmp.path().join("my-mkt");
410        make_marketplace_dir(&mkt_dir, "my-mkt");
411
412        let mut registry = MarketplaceRegistry::new(tmp.path().join("cache"));
413        registry.add(MarketplaceSource::Local { path: mkt_dir }).unwrap();
414        registry.save().unwrap();
415
416        let mut registry2 = MarketplaceRegistry::new(tmp.path().join("cache"));
417        registry2.load().unwrap();
418        assert!(registry2.get("my-mkt").is_some());
419        assert_eq!(registry2.list_all().len(), 2);
420    }
421}