//! Registry client for fetching and parsing pack indices //! //! This module provides functionality for: //! - Fetching index files from HTTP(S) and file:// URLs //! - Caching indices with TTL-based expiration //! - Searching packs across multiple registries //! - Handling authenticated registries use super::{PackIndex, PackIndexEntry}; use crate::config::{PackRegistryConfig, RegistryIndexConfig}; use crate::error::{Error, Result}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime}; /// Cached registry index with expiration #[derive(Clone)] struct CachedIndex { /// The parsed index index: PackIndex, /// When this cache entry was created cached_at: SystemTime, /// TTL in seconds ttl: u64, } impl CachedIndex { /// Check if this cache entry is expired fn is_expired(&self) -> bool { match SystemTime::now().duration_since(self.cached_at) { Ok(duration) => duration.as_secs() > self.ttl, Err(_) => true, // If time went backwards, consider expired } } } /// Registry client for fetching and managing pack indices pub struct RegistryClient { /// Configuration config: PackRegistryConfig, /// HTTP client http_client: reqwest::Client, /// Cache of fetched indices (URL -> CachedIndex) cache: Arc>>, } impl RegistryClient { /// Create a new registry client pub fn new(config: PackRegistryConfig) -> Result { let timeout = Duration::from_secs(config.timeout); let http_client = reqwest::Client::builder() .timeout(timeout) .user_agent(format!( "attune-registry-client/{}", env!("CARGO_PKG_VERSION") )) .build() .map_err(|e| Error::Internal(format!("Failed to create HTTP client: {}", e)))?; Ok(Self { config, http_client, cache: Arc::new(RwLock::new(HashMap::new())), }) } /// Get all enabled registries sorted by priority (lower number = higher priority) pub fn get_registries(&self) -> Vec { let mut registries: Vec<_> = self .config .indices .iter() .filter(|r| r.enabled) .cloned() .collect(); // Sort by priority (ascending) registries.sort_by_key(|r| r.priority); registries } /// Fetch a pack index from a registry pub async fn fetch_index(&self, registry: &RegistryIndexConfig) -> Result { // Check cache first if caching is enabled if self.config.cache_enabled { if let Some(cached) = self.get_cached_index(®istry.url) { if !cached.is_expired() { tracing::debug!("Using cached index for registry: {}", registry.url); return Ok(cached.index); } } } // Fetch fresh index tracing::info!("Fetching index from registry: {}", registry.url); let index = self.fetch_index_from_url(registry).await?; // Cache the result if self.config.cache_enabled { self.cache_index(®istry.url, index.clone()); } Ok(index) } /// Fetch index from URL (bypassing cache) async fn fetch_index_from_url(&self, registry: &RegistryIndexConfig) -> Result { let url = ®istry.url; // Handle file:// URLs if url.starts_with("file://") { return self.fetch_index_from_file(url).await; } // Validate HTTPS if allow_http is false if !self.config.allow_http && url.starts_with("http://") { return Err(Error::Configuration(format!( "HTTP registry not allowed: {}. Set allow_http: true to enable.", url ))); } // Build HTTP request let mut request = self.http_client.get(url); // Add custom headers for (key, value) in ®istry.headers { request = request.header(key, value); } // Send request let response = request .send() .await .map_err(|e| Error::internal(format!("Failed to fetch registry index: {}", e)))?; // Check status if !response.status().is_success() { return Err(Error::internal(format!( "Registry returned error status {}: {}", response.status(), url ))); } // Parse JSON let index: PackIndex = response .json() .await .map_err(|e| Error::internal(format!("Failed to parse registry index: {}", e)))?; Ok(index) } /// Fetch index from file:// URL async fn fetch_index_from_file(&self, url: &str) -> Result { let path = url .strip_prefix("file://") .ok_or_else(|| Error::Configuration(format!("Invalid file URL: {}", url)))?; let path = PathBuf::from(path); let content = tokio::fs::read_to_string(&path) .await .map_err(|e| Error::internal(format!("Failed to read index file: {}", e)))?; let index: PackIndex = serde_json::from_str(&content) .map_err(|e| Error::internal(format!("Failed to parse index file: {}", e)))?; Ok(index) } /// Get cached index if available fn get_cached_index(&self, url: &str) -> Option { let cache = self.cache.read().ok()?; cache.get(url).cloned() } /// Cache an index fn cache_index(&self, url: &str, index: PackIndex) { let cached = CachedIndex { index, cached_at: SystemTime::now(), ttl: self.config.cache_ttl, }; if let Ok(mut cache) = self.cache.write() { cache.insert(url.to_string(), cached); } } /// Clear the cache pub fn clear_cache(&self) { if let Ok(mut cache) = self.cache.write() { cache.clear(); } } /// Search for a pack by reference across all registries pub async fn search_pack(&self, pack_ref: &str) -> Result> { let registries = self.get_registries(); for registry in registries { match self.fetch_index(®istry).await { Ok(index) => { if let Some(pack) = index.packs.iter().find(|p| p.pack_ref == pack_ref) { return Ok(Some((pack.clone(), registry.url.clone()))); } } Err(e) => { tracing::warn!("Failed to fetch registry {}: {}", registry.url, e); continue; } } } Ok(None) } /// Search for packs by keyword across all registries pub async fn search_packs(&self, keyword: &str) -> Result> { let registries = self.get_registries(); let mut results = Vec::new(); let keyword_lower = keyword.to_lowercase(); for registry in registries { match self.fetch_index(®istry).await { Ok(index) => { for pack in index.packs { // Search in ref, label, description, and keywords let matches = pack.pack_ref.to_lowercase().contains(&keyword_lower) || pack.label.to_lowercase().contains(&keyword_lower) || pack.description.to_lowercase().contains(&keyword_lower) || pack .keywords .iter() .any(|k| k.to_lowercase().contains(&keyword_lower)); if matches { results.push((pack, registry.url.clone())); } } } Err(e) => { tracing::warn!("Failed to fetch registry {}: {}", registry.url, e); continue; } } } Ok(results) } /// Get pack from specific registry pub async fn get_pack_from_registry( &self, pack_ref: &str, registry_name: &str, ) -> Result> { // Find registry by name let registry = self .config .indices .iter() .find(|r| r.name.as_deref() == Some(registry_name)) .ok_or_else(|| Error::not_found("registry", "name", registry_name))?; let index = self.fetch_index(registry).await?; Ok(index.packs.into_iter().find(|p| p.pack_ref == pack_ref)) } } #[cfg(test)] mod tests { use super::*; use crate::config::RegistryIndexConfig; #[test] fn test_cached_index_expiration() { let index = PackIndex { registry_name: "Test".to_string(), registry_url: "https://example.com".to_string(), version: "1.0".to_string(), last_updated: "2024-01-20T12:00:00Z".to_string(), packs: vec![], }; let cached = CachedIndex { index, cached_at: SystemTime::now(), ttl: 3600, }; assert!(!cached.is_expired()); // Test with expired cache let cached_old = CachedIndex { index: cached.index.clone(), cached_at: SystemTime::now() - Duration::from_secs(7200), ttl: 3600, }; assert!(cached_old.is_expired()); } #[test] fn test_get_registries_sorted() { let config = PackRegistryConfig { enabled: true, indices: vec![ RegistryIndexConfig { url: "https://registry3.example.com".to_string(), priority: 3, enabled: true, name: Some("Registry 3".to_string()), headers: HashMap::new(), }, RegistryIndexConfig { url: "https://registry1.example.com".to_string(), priority: 1, enabled: true, name: Some("Registry 1".to_string()), headers: HashMap::new(), }, RegistryIndexConfig { url: "https://registry2.example.com".to_string(), priority: 2, enabled: true, name: Some("Registry 2".to_string()), headers: HashMap::new(), }, RegistryIndexConfig { url: "https://disabled.example.com".to_string(), priority: 0, enabled: false, name: Some("Disabled".to_string()), headers: HashMap::new(), }, ], cache_ttl: 3600, cache_enabled: true, timeout: 120, verify_checksums: true, allowed_source_hosts: Vec::new(), allow_http: false, }; let client = RegistryClient::new(config).unwrap(); let registries = client.get_registries(); assert_eq!(registries.len(), 3); // Disabled one excluded assert_eq!(registries[0].priority, 1); assert_eq!(registries[1].priority, 2); assert_eq!(registries[2].priority, 3); } }