Skip to main content

plugin_packager/
remote.rs

1// Copyright 2024 Vincents AI
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Remote registry integration for plugin-packager
5//!
6//! This module provides integration with the plugin registry system,
7//! allowing plugins to be discovered and managed from remote registries
8//! while maintaining compatibility with the local registry.
9
10use anyhow::Result;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::time::{Duration, SystemTime};
14
15use crate::registry::{LocalRegistry, PluginRegistryEntry};
16
17/// Remote registry client configuration
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct RemoteRegistryConfig {
20    /// Registry URL (e.g., "https://registry.example.com")
21    pub url: String,
22
23    /// Authentication token (optional)
24    pub token: Option<String>,
25
26    /// Request timeout duration
27    #[serde(default = "RemoteRegistryConfig::default_timeout")]
28    pub timeout_secs: u64,
29
30    /// Cache expiration time
31    #[serde(default = "RemoteRegistryConfig::default_cache_ttl")]
32    pub cache_ttl_secs: u64,
33
34    /// Whether to verify SSL certificates
35    #[serde(default = "RemoteRegistryConfig::default_verify_ssl")]
36    pub verify_ssl: bool,
37}
38
39impl RemoteRegistryConfig {
40    fn default_timeout() -> u64 {
41        30
42    }
43
44    fn default_cache_ttl() -> u64 {
45        3600 // 1 hour
46    }
47
48    fn default_verify_ssl() -> bool {
49        true
50    }
51
52    /// Create configuration with defaults
53    pub fn new(url: String) -> Self {
54        Self {
55            url,
56            token: None,
57            timeout_secs: Self::default_timeout(),
58            cache_ttl_secs: Self::default_cache_ttl(),
59            verify_ssl: Self::default_verify_ssl(),
60        }
61    }
62
63    /// Set authentication token
64    pub fn with_token(mut self, token: String) -> Self {
65        self.token = Some(token);
66        self
67    }
68
69    /// Set request timeout
70    pub fn with_timeout(mut self, secs: u64) -> Self {
71        self.timeout_secs = secs;
72        self
73    }
74
75    /// Set cache TTL
76    pub fn with_cache_ttl(mut self, secs: u64) -> Self {
77        self.cache_ttl_secs = secs;
78        self
79    }
80}
81
82/// Remote registry client
83pub struct RemoteRegistry {
84    config: RemoteRegistryConfig,
85    cache: HashMap<String, CachedEntry>,
86}
87
88/// Cached remote registry entry
89#[derive(Clone, Debug)]
90struct CachedEntry {
91    /// Cached plugin data
92    entry: PluginRegistryEntry,
93    /// Cache timestamp
94    cached_at: SystemTime,
95}
96
97impl CachedEntry {
98    /// Check if cache entry has expired
99    fn is_expired(&self, ttl: Duration) -> bool {
100        self.cached_at.elapsed().unwrap_or(ttl) > ttl
101    }
102}
103
104impl RemoteRegistry {
105    /// Create new remote registry client
106    pub fn new(config: RemoteRegistryConfig) -> Self {
107        Self {
108            config,
109            cache: HashMap::new(),
110        }
111    }
112
113    /// Search remote registry for plugins
114    pub fn search(&mut self, _query: &str, _limit: usize) -> Result<Vec<PluginRegistryEntry>> {
115        // In a real implementation, this would make HTTP requests to the remote registry
116        // For now, this is a placeholder that demonstrates the interface
117
118        // In production, this would:
119        // 1. Check cache first
120        // 2. Make HTTP GET request to {registry_url}/api/search?q={query}&limit={limit}
121        // 3. Parse JSON response
122        // 4. Cache results with TTL
123        // 5. Return results
124
125        // This is designed to be compatible with the plugin-registry API
126        Ok(Vec::new())
127    }
128
129    /// Get plugin from remote registry
130    pub fn get_plugin(&mut self, name: &str, version: Option<&str>) -> Result<PluginRegistryEntry> {
131        // Check cache first
132        let ttl = Duration::from_secs(self.config.cache_ttl_secs);
133        let cache_key = if let Some(v) = version {
134            format!("{}:{}", name, v)
135        } else {
136            name.to_string()
137        };
138
139        if let Some(cached) = self.cache.get(&cache_key) {
140            if !cached.is_expired(ttl) {
141                return Ok(cached.entry.clone());
142            }
143        }
144
145        // In production, would make HTTP request here
146        // GET {registry_url}/api/plugins/{name}/{version}
147        // Parse response and cache it
148
149        Err(anyhow::anyhow!(
150            "Plugin {}:{} not found in remote registry",
151            name,
152            version.unwrap_or("latest")
153        ))
154    }
155
156    /// Fetch latest version of a plugin
157    pub fn get_latest(&mut self, name: &str) -> Result<PluginRegistryEntry> {
158        self.get_plugin(name, None)
159    }
160
161    /// Clear cache
162    pub fn clear_cache(&mut self) {
163        self.cache.clear();
164    }
165
166    /// Get cache size
167    pub fn cache_size(&self) -> usize {
168        self.cache.len()
169    }
170
171    /// Prune expired entries from cache
172    pub fn prune_cache(&mut self) {
173        let ttl = Duration::from_secs(self.config.cache_ttl_secs);
174        self.cache.retain(|_, entry| !entry.is_expired(ttl));
175    }
176}
177
178/// Combined local and remote registry client
179pub struct HybridRegistry {
180    local: LocalRegistry,
181    remote: Option<RemoteRegistry>,
182    /// Whether to check local registry first (true) or remote first (false)
183    local_first: bool,
184}
185
186impl HybridRegistry {
187    /// Create hybrid registry with only local
188    pub fn local_only(local: LocalRegistry) -> Self {
189        Self {
190            local,
191            remote: None,
192            local_first: true,
193        }
194    }
195
196    /// Create hybrid registry with both local and remote
197    pub fn with_remote(local: LocalRegistry, remote: RemoteRegistry) -> Self {
198        Self {
199            local,
200            remote: Some(remote),
201            local_first: true,
202        }
203    }
204
205    /// Set search priority (true: local first, false: remote first)
206    pub fn set_local_first(mut self, local_first: bool) -> Self {
207        self.local_first = local_first;
208        self
209    }
210
211    /// Search for plugin in registry (local first by default)
212    pub fn search(&mut self, query: &str, limit: usize) -> Result<Vec<PluginRegistryEntry>> {
213        if self.local_first {
214            // Search local first
215            let local_results: Vec<_> = self.local.search(query).into_iter().take(limit).collect();
216
217            if !local_results.is_empty() {
218                return Ok(local_results);
219            }
220
221            // Fall back to remote if available
222            if let Some(remote) = &mut self.remote {
223                return remote.search(query, limit);
224            }
225
226            Ok(Vec::new())
227        } else {
228            // Search remote first
229            if let Some(remote) = &mut self.remote {
230                let remote_results = remote.search(query, limit)?;
231                if !remote_results.is_empty() {
232                    return Ok(remote_results);
233                }
234            }
235
236            // Fall back to local
237            let local_results: Vec<_> = self.local.search(query).into_iter().take(limit).collect();
238            Ok(local_results)
239        }
240    }
241
242    /// Get plugin from registry
243    pub fn get(&mut self, name: &str, version: Option<&str>) -> Result<PluginRegistryEntry> {
244        if self.local_first {
245            // Try local first
246            if let Some(v) = version {
247                if let Some(entry) = self.local.find_by_version(name, v) {
248                    return Ok(entry);
249                }
250            } else if let Some(entry) = self.local.find_by_name(name) {
251                return Ok(entry);
252            }
253
254            // Try remote
255            if let Some(remote) = &mut self.remote {
256                return remote.get_plugin(name, version);
257            }
258
259            Err(anyhow::anyhow!("Plugin {} not found", name))
260        } else {
261            // Try remote first
262            if let Some(remote) = &mut self.remote {
263                if let Ok(entry) = remote.get_plugin(name, version) {
264                    return Ok(entry);
265                }
266            }
267
268            // Try local
269            if let Some(v) = version {
270                if let Some(entry) = self.local.find_by_version(name, v) {
271                    return Ok(entry);
272                }
273            } else if let Some(entry) = self.local.find_by_name(name) {
274                return Ok(entry);
275            }
276
277            Err(anyhow::anyhow!("Plugin {} not found", name))
278        }
279    }
280
281    /// Register plugin in local registry
282    pub fn register_local(&mut self, entry: PluginRegistryEntry) -> Result<()> {
283        self.local.register(entry)
284    }
285
286    /// Get local registry reference
287    pub fn local_registry(&self) -> &LocalRegistry {
288        &self.local
289    }
290
291    /// Sync plugins from remote to local cache
292    pub fn sync_from_remote(&mut self, plugins: Vec<&str>) -> Result<usize> {
293        let mut synced = 0;
294
295        if let Some(remote) = &mut self.remote {
296            for plugin_name in plugins {
297                if let Ok(entry) = remote.get_plugin(plugin_name, None) {
298                    let _ = self.local.register(entry);
299                    synced += 1;
300                }
301            }
302        }
303
304        Ok(synced)
305    }
306
307    /// Prune remote cache
308    pub fn prune_remote_cache(&mut self) {
309        if let Some(remote) = &mut self.remote {
310            remote.prune_cache();
311        }
312    }
313
314    /// Get cache statistics
315    pub fn cache_stats(&self) -> CacheStats {
316        let remote_cache_size = self.remote.as_ref().map(|r| r.cache_size()).unwrap_or(0);
317
318        CacheStats {
319            local_plugins: self.local.count(),
320            remote_cache_size,
321            local_first: self.local_first,
322        }
323    }
324}
325
326/// Cache statistics
327#[derive(Debug, Clone)]
328pub struct CacheStats {
329    pub local_plugins: usize,
330    pub remote_cache_size: usize,
331    pub local_first: bool,
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_remote_registry_config() {
340        let config = RemoteRegistryConfig::new("https://registry.example.com".to_string());
341        assert_eq!(config.url, "https://registry.example.com");
342        assert_eq!(config.timeout_secs, 30);
343        assert_eq!(config.cache_ttl_secs, 3600);
344        assert!(config.verify_ssl);
345    }
346
347    #[test]
348    fn test_remote_registry_config_builder() {
349        let config = RemoteRegistryConfig::new("https://api.example.com".to_string())
350            .with_token("secret-token".to_string())
351            .with_timeout(60)
352            .with_cache_ttl(7200);
353
354        assert_eq!(config.url, "https://api.example.com");
355        assert_eq!(config.token, Some("secret-token".to_string()));
356        assert_eq!(config.timeout_secs, 60);
357        assert_eq!(config.cache_ttl_secs, 7200);
358    }
359
360    #[test]
361    fn test_hybrid_registry_local_only() {
362        let local = LocalRegistry::new();
363        let hybrid = HybridRegistry::local_only(local);
364
365        assert!(hybrid.remote.is_none());
366        assert!(hybrid.local_first);
367    }
368
369    #[test]
370    fn test_hybrid_registry_with_remote() {
371        let local = LocalRegistry::new();
372        let config = RemoteRegistryConfig::new("https://example.com".to_string());
373        let remote = RemoteRegistry::new(config);
374        let hybrid = HybridRegistry::with_remote(local, remote);
375
376        assert!(hybrid.remote.is_some());
377        assert!(hybrid.local_first);
378    }
379
380    #[test]
381    fn test_hybrid_registry_priority() {
382        let local = LocalRegistry::new();
383        let config = RemoteRegistryConfig::new("https://example.com".to_string());
384        let remote = RemoteRegistry::new(config);
385
386        let hybrid = HybridRegistry::with_remote(local, remote).set_local_first(false);
387        assert!(!hybrid.local_first);
388    }
389
390    #[test]
391    fn test_cached_entry_expiration() {
392        let entry = PluginRegistryEntry {
393            plugin_id: "test".to_string(),
394            name: "test".to_string(),
395            version: "1.0.0".to_string(),
396            abi_version: "2.0".to_string(),
397            description: None,
398            author: None,
399            license: None,
400            keywords: None,
401            dependencies: None,
402        };
403
404        let cached = CachedEntry {
405            entry,
406            cached_at: SystemTime::now(),
407        };
408
409        // Should not be expired with generous TTL
410        let generous_ttl = Duration::from_secs(3600);
411        assert!(!cached.is_expired(generous_ttl));
412
413        // Should be expired with very short TTL
414        let short_ttl = Duration::from_secs(0);
415        assert!(cached.is_expired(short_ttl));
416    }
417
418    #[test]
419    fn test_hybrid_registry_search_local() -> Result<()> {
420        let mut local = LocalRegistry::new();
421        let entry = PluginRegistryEntry {
422            plugin_id: "test".to_string(),
423            name: "test".to_string(),
424            version: "1.0.0".to_string(),
425            abi_version: "2.0".to_string(),
426            description: Some("Test plugin".to_string()),
427            author: None,
428            license: None,
429            keywords: None,
430            dependencies: None,
431        };
432        local.register(entry)?;
433
434        let mut hybrid = HybridRegistry::local_only(local);
435        let results = hybrid.search("test", 10)?;
436
437        assert_eq!(results.len(), 1);
438        assert_eq!(results[0].name, "test");
439
440        Ok(())
441    }
442
443    #[test]
444    fn test_cache_stats() {
445        let local = LocalRegistry::new();
446        let config = RemoteRegistryConfig::new("https://example.com".to_string());
447        let remote = RemoteRegistry::new(config);
448        let hybrid = HybridRegistry::with_remote(local, remote);
449
450        let stats = hybrid.cache_stats();
451        assert_eq!(stats.local_plugins, 0);
452        assert_eq!(stats.remote_cache_size, 0);
453        assert!(stats.local_first);
454    }
455}