telos_agent/integrations/plugin/
marketplace.rs1use 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#[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#[derive(Debug, Clone)]
26struct CachedMarketplace {
27 source: MarketplaceSource,
28 manifest: Marketplace,
29 install_location: PathBuf,
31 last_updated: u64,
33}
34
35pub struct MarketplaceRegistry {
37 marketplaces: HashMap<String, CachedMarketplace>,
38 cache_root: PathBuf,
39}
40
41impl MarketplaceRegistry {
42 pub fn new(cache_root: impl Into<PathBuf>) -> Self {
44 Self { marketplaces: HashMap::new(), cache_root: cache_root.into() }
45 }
46
47 pub fn add(&mut self, source: MarketplaceSource) -> Result<String, PluginError> {
53 let name = match &source {
54 MarketplaceSource::GitHub { repo, .. } => {
55 repo.split('/').next_back().unwrap_or(repo).to_string()
57 }
58 MarketplaceSource::Git { url, .. } => {
59 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 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 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 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 pub fn get(&self, name: &str) -> Option<&Marketplace> {
131 self.marketplaces.get(name).map(|c| &c.manifest)
132 }
133
134 pub fn names(&self) -> Vec<&String> {
136 self.marketplaces.keys().collect()
137 }
138
139 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 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 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 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; }
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 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 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 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 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}