//! Registry catalog: loads manifests from disk, provides list/search/resolve operations. use std::collections::HashMap; use std::path::{Path, PathBuf}; use crate::registry::embedded; use crate::registry::manifest::{BundleDefinition, BundlesFile, ExtensionManifest, ManifestKind}; /// Error type for registry operations. #[derive(Debug, thiserror::Error)] pub enum RegistryError { #[error("Registry directory not found: {0}")] DirectoryNotFound(PathBuf), #[error("Failed to read manifest {path}: {reason}")] ManifestRead { path: PathBuf, reason: String }, #[error("Failed to parse manifest {path}: {reason}")] ManifestParse { path: PathBuf, reason: String }, #[error("Extension not found: {0}")] ExtensionNotFound(String), #[error("'{name}' already installed at {path}. Use --force to overwrite.")] AlreadyInstalled { name: String, path: std::path::PathBuf, }, // `url` is stored for programmatic access (logs, retries) but intentionally // omitted from the Display message to avoid leaking internal artifact URLs // to end users. #[error("Artifact download failed: {reason}")] DownloadFailed { url: String, reason: String }, #[error("Invalid extension manifest for '{name}' field '{field}': {reason}")] InvalidManifest { name: String, field: &'static str, reason: String, }, #[error("Checksum verification failed: expected {expected_sha256}, got {actual_sha256}")] ChecksumMismatch { url: String, expected_sha256: String, actual_sha256: String, }, #[error("Missing SHA256 checksum for '{name}' artifact. Use --build to build from source.")] MissingChecksum { name: String }, #[error( "Source fallback unavailable for '{name}' after artifact install failed. Retry artifact download or run from a repository checkout." )] SourceFallbackUnavailable { name: String, source_dir: PathBuf, artifact_error: Box, }, #[error("Artifact install and source fallback both failed for '{name}'.")] InstallFallbackFailed { name: String, artifact_error: Box, source_error: Box, }, #[error( "Ambiguous name '{name}': exists as both {kind_a} and {kind_b}. Use '{prefix_a}/{name}' or '{prefix_b}/{name}'." )] AmbiguousName { name: String, kind_a: &'static str, prefix_a: &'static str, kind_b: &'static str, prefix_b: &'static str, }, #[error("Bundle not found: {0}")] BundleNotFound(String), #[error("Failed to read bundles file: {0}")] BundlesRead(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), } /// Central catalog loaded from the `registry/` directory. #[derive(Debug, Clone)] pub struct RegistryCatalog { /// All loaded manifests, keyed by "/" (e.g. "tools/github"). manifests: HashMap, /// Bundle definitions from `_bundles.json`. bundles: HashMap, /// Root directory of the registry. root: PathBuf, } impl RegistryCatalog { /// Find the `registry/` directory by searching relative to cwd, the executable, /// and `CARGO_MANIFEST_DIR`. Returns `None` if the directory cannot be found /// (non-fatal at startup). pub fn find_dir() -> Option { // Try relative to current directory (for dev usage) if let Ok(cwd) = std::env::current_dir() { let candidate = cwd.join("registry"); if candidate.is_dir() { return Some(candidate); } } // Try relative to executable (covers installed binary, target/debug/, target/release/) if let Ok(exe) = std::env::current_exe() && let Some(parent) = exe.parent() { // Walk up to 3 levels: exe dir, parent (target/release -> target), grandparent (-> repo root) let mut dir = Some(parent); for _ in 0..3 { if let Some(d) = dir { let candidate = d.join("registry"); if candidate.is_dir() { return Some(candidate); } dir = d.parent(); } } } // Try CARGO_MANIFEST_DIR (compile-time, works in dev builds) let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let candidate = manifest_dir.join("registry"); if candidate.is_dir() { return Some(candidate); } None } /// Try to load from disk; if `registry/` cannot be found, fall back to /// manifests embedded into the binary at compile time. pub fn load_or_embedded() -> Result { if let Some(dir) = Self::find_dir() { return Self::load(&dir); } // Fall back to embedded catalog let manifests = embedded::load_embedded(); let bundles = embedded::load_embedded_bundles(); tracing::info!( "Loaded embedded registry catalog ({} extensions, {} bundles)", manifests.len(), bundles.len() ); Ok(Self { manifests, bundles, root: PathBuf::new(), }) } /// Load the catalog from a registry directory. /// /// Expects the structure: /// ```text /// registry/ /// ├── tools/*.json /// ├── channels/*.json /// └── _bundles.json /// ``` pub fn load(registry_dir: &Path) -> Result { if !registry_dir.exists() { return Err(RegistryError::DirectoryNotFound(registry_dir.to_path_buf())); } let mut manifests = HashMap::new(); // Load tools let tools_dir = registry_dir.join("tools"); if tools_dir.is_dir() { Self::load_manifests_from_dir(&tools_dir, "tools", &mut manifests)?; } // Load channels let channels_dir = registry_dir.join("channels"); if channels_dir.is_dir() { Self::load_manifests_from_dir(&channels_dir, "channels", &mut manifests)?; } // Load MCP servers let mcp_servers_dir = registry_dir.join("mcp-servers"); if mcp_servers_dir.is_dir() { Self::load_manifests_from_dir(&mcp_servers_dir, "mcp-servers", &mut manifests)?; } // Load bundles let bundles_path = registry_dir.join("_bundles.json"); let bundles = if bundles_path.is_file() { let content = std::fs::read_to_string(&bundles_path).map_err(|e| { RegistryError::BundlesRead(format!("{}: {}", bundles_path.display(), e)) })?; let bundles_file: BundlesFile = serde_json::from_str(&content).map_err(|e| { RegistryError::BundlesRead(format!("{}: {}", bundles_path.display(), e)) })?; bundles_file.bundles } else { HashMap::new() }; Ok(Self { manifests, bundles, root: registry_dir.to_path_buf(), }) } fn load_manifests_from_dir( dir: &Path, kind_prefix: &str, manifests: &mut HashMap, ) -> Result<(), RegistryError> { let entries = std::fs::read_dir(dir).map_err(|e| RegistryError::ManifestRead { path: dir.to_path_buf(), reason: e.to_string(), })?; for entry in entries { let entry = entry.map_err(|e| RegistryError::ManifestRead { path: dir.to_path_buf(), reason: e.to_string(), })?; let path = entry.path(); if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } let content = std::fs::read_to_string(&path).map_err(|e| RegistryError::ManifestRead { path: path.clone(), reason: e.to_string(), })?; let manifest: ExtensionManifest = serde_json::from_str(&content).map_err(|e| RegistryError::ManifestParse { path: path.clone(), reason: e.to_string(), })?; let key = format!("{}/{}", kind_prefix, manifest.name); manifests.insert(key, manifest); } Ok(()) } /// The root directory this catalog was loaded from. pub fn root(&self) -> &Path { &self.root } /// Get all manifests. pub fn all(&self) -> Vec<&ExtensionManifest> { let mut items: Vec<_> = self.manifests.values().collect(); items.sort_by(|a, b| a.name.cmp(&b.name)); items } /// List manifests, optionally filtered by kind and/or tag. pub fn list(&self, kind: Option, tag: Option<&str>) -> Vec<&ExtensionManifest> { let mut results: Vec<_> = self .manifests .values() .filter(|m| kind.is_none_or(|k| m.kind == k)) .filter(|m| tag.is_none_or(|t| m.tags.iter().any(|mt| mt == t))) .collect(); results.sort_by(|a, b| a.name.cmp(&b.name)); results } /// Get a manifest by name. Tries exact key match first ("tools/github"), /// then searches by bare name ("github"). /// /// If a bare name matches more than one prefix, returns `None`. /// Use a qualified key ("tools/github", "channels/telegram", or /// "mcp-servers/notion") to disambiguate. pub fn get(&self, name: &str) -> Option<&ExtensionManifest> { // Try exact key first if let Some(m) = self.manifests.get(name) { return Some(m); } // Try with kind prefix, detecting collisions let candidates: Vec<_> = ["tools", "channels", "mcp-servers"] .iter() .filter_map(|prefix| self.manifests.get(&format!("{}/{}", prefix, name))) .collect(); if candidates.len() == 1 { Some(candidates[0]) } else { None // ambiguous or not found } } /// Get a manifest by name, returning a `Result` with an explicit error for /// ambiguous bare names. pub fn get_strict(&self, name: &str) -> Result<&ExtensionManifest, RegistryError> { // Try exact key first if let Some(m) = self.manifests.get(name) { return Ok(m); } let prefixes: &[(&str, &str)] = &[ ("tools", "tool"), ("channels", "channel"), ("mcp-servers", "mcp_server"), ]; let matches: Vec<_> = prefixes .iter() .filter(|(prefix, _)| self.manifests.contains_key(&format!("{}/{}", prefix, name))) .collect(); match matches.len() { 0 => Err(RegistryError::ExtensionNotFound(name.to_string())), 1 => { let (prefix, _) = matches[0]; let key = format!("{}/{}", prefix, name); self.manifests .get(&key) .ok_or_else(|| RegistryError::ExtensionNotFound(name.to_string())) } _ => { let (prefix_a, kind_a) = matches[0]; let (prefix_b, kind_b) = matches[1]; Err(RegistryError::AmbiguousName { name: name.to_string(), kind_a, prefix_a, kind_b, prefix_b, }) } } } /// Get the full key ("tools/github", "channels/telegram", or /// "mcp-servers/notion") for a manifest. pub fn key_for(&self, name: &str) -> Option { if self.manifests.contains_key(name) { return Some(name.to_string()); } let matches: Vec = ["tools", "channels", "mcp-servers"] .iter() .filter_map(|prefix| { let key = format!("{}/{}", prefix, name); if self.manifests.contains_key(&key) { Some(key) } else { None } }) .collect(); if matches.len() == 1 { matches.into_iter().next() } else { None // ambiguous or not found } } /// Search manifests by query string (matches name, display_name, description, keywords). pub fn search(&self, query: &str) -> Vec<&ExtensionManifest> { let query_lower = query.to_lowercase(); let tokens: Vec<&str> = query_lower.split_whitespace().collect(); let mut scored: Vec<(&ExtensionManifest, usize)> = self .manifests .values() .filter_map(|m| { let score = Self::score_manifest(m, &tokens); if score > 0 { Some((m, score)) } else { None } }) .collect(); scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.name.cmp(&b.0.name))); scored.into_iter().map(|(m, _)| m).collect() } fn score_manifest(manifest: &ExtensionManifest, tokens: &[&str]) -> usize { let mut score = 0; let name_lower = manifest.name.to_lowercase(); let display_lower = manifest.display_name.to_lowercase(); let desc_lower = manifest.description.to_lowercase(); for token in tokens { if name_lower == *token { score += 10; } else if name_lower.contains(token) { score += 5; } if display_lower == *token { score += 8; } else if display_lower.contains(token) { score += 4; } if desc_lower.contains(token) { score += 2; } for kw in &manifest.keywords { if kw.to_lowercase() == *token { score += 6; } else if kw.to_lowercase().contains(token) { score += 3; } } for tag in &manifest.tags { if tag.to_lowercase() == *token { score += 4; } } } score } /// Get a bundle definition by name. pub fn get_bundle(&self, name: &str) -> Option<&BundleDefinition> { self.bundles.get(name) } /// List all bundle names. pub fn bundle_names(&self) -> Vec<&str> { let mut names: Vec<_> = self.bundles.keys().map(|s| s.as_str()).collect(); names.sort(); names } /// Resolve a bundle into its constituent manifests. /// Returns the manifests and any extension keys that couldn't be found. pub fn resolve_bundle( &self, bundle_name: &str, ) -> Result<(Vec<&ExtensionManifest>, Vec), RegistryError> { let bundle = self .bundles .get(bundle_name) .ok_or_else(|| RegistryError::BundleNotFound(bundle_name.to_string()))?; let mut found = Vec::new(); let mut missing = Vec::new(); for ext_key in &bundle.extensions { if let Some(manifest) = self.manifests.get(ext_key) { found.push(manifest); } else { missing.push(ext_key.clone()); } } Ok((found, missing)) } /// Check if a name refers to a bundle rather than an individual extension. pub fn is_bundle(&self, name: &str) -> bool { self.bundles.contains_key(name) } /// Resolve a name to either a single manifest or the manifests in a bundle. /// Returns (manifests, bundle_definition_if_bundle). pub fn resolve( &self, name: &str, ) -> Result<(Vec<&ExtensionManifest>, Option<&BundleDefinition>), RegistryError> { // Check bundle first if let Some(bundle) = self.bundles.get(name) { let (manifests, missing) = self.resolve_bundle(name)?; if !missing.is_empty() { tracing::warn!( "Bundle '{}' references missing extensions: {:?}", name, missing ); } return Ok((manifests, Some(bundle))); } // Single extension (use get_strict to catch ambiguous bare names) let manifest = self.get_strict(name)?; Ok((vec![manifest], None)) } } #[cfg(test)] mod tests { use super::*; use std::fs; fn create_test_registry(dir: &Path) { let tools_dir = dir.join("tools"); let channels_dir = dir.join("channels"); let mcp_dir = dir.join("mcp-servers"); fs::create_dir_all(&tools_dir).unwrap(); fs::create_dir_all(&channels_dir).unwrap(); fs::create_dir_all(&mcp_dir).unwrap(); fs::write( tools_dir.join("slack.json"), r#"{ "name": "slack", "display_name": "Slack", "kind": "tool", "version": "0.1.0", "description": "Post messages via Slack API", "keywords": ["messaging", "chat"], "source": { "dir": "tools-src/slack", "capabilities": "slack-tool.capabilities.json", "crate_name": "slack-tool" }, "auth_summary": { "method": "oauth", "provider": "Slack", "secrets": ["slack_bot_token"] }, "tags": ["default", "messaging"] }"#, ) .unwrap(); fs::write( tools_dir.join("github.json"), r#"{ "name": "github", "display_name": "GitHub", "kind": "tool", "version": "0.1.0", "description": "GitHub integration for issues and PRs", "keywords": ["code", "git"], "source": { "dir": "tools-src/github", "capabilities": "github-tool.capabilities.json", "crate_name": "github-tool" }, "tags": ["default", "development"] }"#, ) .unwrap(); fs::write( channels_dir.join("telegram.json"), r#"{ "name": "telegram", "display_name": "Telegram", "kind": "channel", "version": "0.1.0", "description": "Telegram Bot API channel", "source": { "dir": "channels-src/telegram", "capabilities": "telegram.capabilities.json", "crate_name": "telegram-channel" }, "tags": ["messaging"] }"#, ) .unwrap(); fs::write( mcp_dir.join("notion.json"), r#"{ "name": "notion", "display_name": "Notion", "kind": "mcp_server", "description": "Connect to Notion for pages and databases", "keywords": ["notes", "wiki"], "url": "https://mcp.notion.com/mcp", "auth": "dcr" }"#, ) .unwrap(); fs::write( dir.join("_bundles.json"), r#"{ "bundles": { "default": { "display_name": "Recommended", "extensions": ["tools/slack", "tools/github", "channels/telegram"] }, "messaging": { "display_name": "Messaging", "extensions": ["tools/slack", "channels/telegram"], "shared_auth": null } } }"#, ) .unwrap(); } #[test] fn test_load_catalog() { let tmp = tempfile::tempdir().unwrap(); create_test_registry(tmp.path()); let catalog = RegistryCatalog::load(tmp.path()).unwrap(); assert_eq!(catalog.all().len(), 4); } #[test] fn test_list_by_kind() { let tmp = tempfile::tempdir().unwrap(); create_test_registry(tmp.path()); let catalog = RegistryCatalog::load(tmp.path()).unwrap(); let tools = catalog.list(Some(ManifestKind::Tool), None); assert_eq!(tools.len(), 2); let channels = catalog.list(Some(ManifestKind::Channel), None); assert_eq!(channels.len(), 1); let mcp_servers = catalog.list(Some(ManifestKind::McpServer), None); assert_eq!(mcp_servers.len(), 1); } #[test] fn test_list_by_tag() { let tmp = tempfile::tempdir().unwrap(); create_test_registry(tmp.path()); let catalog = RegistryCatalog::load(tmp.path()).unwrap(); let defaults = catalog.list(None, Some("default")); assert_eq!(defaults.len(), 2); let messaging = catalog.list(None, Some("messaging")); assert_eq!(messaging.len(), 2); // slack (tool) and telegram (channel) both have "messaging" tag } #[test] fn test_get_by_name() { let tmp = tempfile::tempdir().unwrap(); create_test_registry(tmp.path()); let catalog = RegistryCatalog::load(tmp.path()).unwrap(); // Full key assert!(catalog.get("tools/slack").is_some()); assert!(catalog.get("mcp-servers/notion").is_some()); // Bare name assert!(catalog.get("slack").is_some()); assert!(catalog.get("telegram").is_some()); assert!(catalog.get("notion").is_some()); // Missing assert!(catalog.get("nonexistent").is_none()); } #[test] fn test_search() { let tmp = tempfile::tempdir().unwrap(); create_test_registry(tmp.path()); let catalog = RegistryCatalog::load(tmp.path()).unwrap(); let results = catalog.search("slack"); assert_eq!(results.len(), 1); assert_eq!(results[0].name, "slack"); let results = catalog.search("messaging"); assert!(!results.is_empty()); let results = catalog.search("nonexistent query"); assert!(results.is_empty()); } #[test] fn test_resolve_bundle() { let tmp = tempfile::tempdir().unwrap(); create_test_registry(tmp.path()); let catalog = RegistryCatalog::load(tmp.path()).unwrap(); let (manifests, missing) = catalog.resolve_bundle("default").unwrap(); assert_eq!(manifests.len(), 3); assert!(missing.is_empty()); assert!(catalog.resolve_bundle("nonexistent").is_err()); } #[test] fn test_resolve_single_or_bundle() { let tmp = tempfile::tempdir().unwrap(); create_test_registry(tmp.path()); let catalog = RegistryCatalog::load(tmp.path()).unwrap(); // Single extension let (manifests, bundle) = catalog.resolve("slack").unwrap(); assert_eq!(manifests.len(), 1); assert!(bundle.is_none()); // Bundle let (manifests, bundle) = catalog.resolve("default").unwrap(); assert_eq!(manifests.len(), 3); assert!(bundle.is_some()); } #[test] fn test_bundle_names() { let tmp = tempfile::tempdir().unwrap(); create_test_registry(tmp.path()); let catalog = RegistryCatalog::load(tmp.path()).unwrap(); let names = catalog.bundle_names(); assert_eq!(names, vec!["default", "messaging"]); } #[test] fn test_directory_not_found() { let result = RegistryCatalog::load(Path::new("/nonexistent/path")); assert!(result.is_err()); } #[test] fn test_load_or_embedded_succeeds() { // Should always succeed: either finds registry/ on disk or falls back to embedded let catalog = RegistryCatalog::load_or_embedded().unwrap(); // At minimum, the embedded catalog from the repo should have entries assert!(!catalog.all().is_empty() || !catalog.bundle_names().is_empty()); } #[test] fn test_bundle_entries_resolve_against_real_registry() { // Load the actual registry/ directory (catches stale bundle refs after renames) let catalog = RegistryCatalog::load_or_embedded().unwrap(); for bundle_name in catalog.bundle_names() { let (manifests, missing) = catalog.resolve_bundle(bundle_name).unwrap(); assert!( missing.is_empty(), "Bundle '{}' has unresolved entries: {:?}. \ Check that _bundles.json entries match manifest name fields.", bundle_name, missing ); assert!( !manifests.is_empty(), "Bundle '{}' resolved to zero manifests", bundle_name ); } } }