//! Curated in-memory catalog of known extensions with fuzzy search. //! //! The registry holds well-known channels, tools, and MCP servers that can be //! installed via conversational commands. Online discoveries are cached here too. use tokio::sync::RwLock; use crate::extensions::{ AuthHint, ExtensionKind, ExtensionSource, RegistryEntry, ResultSource, SearchResult, }; /// Curated extension registry with fuzzy search. pub struct ExtensionRegistry { /// Built-in curated entries. entries: Vec, /// Cached entries from online discovery (session-lived). discovery_cache: RwLock>, } impl ExtensionRegistry { /// Create a new registry populated with known extensions. pub fn new() -> Self { Self { entries: builtin_entries(), discovery_cache: RwLock::new(Vec::new()), } } /// Create a new registry merging builtin entries with catalog-provided entries. /// /// Deduplicates by `(name, kind)` pair -- a builtin MCP "slack" and a registry /// WASM "slack" can coexist since they're different kinds. pub fn new_with_catalog(catalog_entries: Vec) -> Self { let mut entries = builtin_entries(); for entry in catalog_entries { if !entries .iter() .any(|e| e.name == entry.name && e.kind == entry.kind) { entries.push(entry); } } Self { entries, discovery_cache: RwLock::new(Vec::new()), } } /// Search the registry by query string. Returns results sorted by relevance. /// /// Splits the query into lowercase tokens and scores each entry by matches /// in name, keywords, and description. pub async fn search(&self, query: &str) -> Vec { let tokens: Vec = query .to_lowercase() .split_whitespace() .map(|s| s.to_string()) .collect(); if tokens.is_empty() { // Return all entries when query is empty return self .entries .iter() .map(|e| SearchResult { entry: e.clone(), source: ResultSource::Registry, validated: true, }) .collect(); } let mut scored: Vec<(SearchResult, u32)> = Vec::new(); // Score built-in entries for entry in &self.entries { let score = score_entry(entry, &tokens); if score > 0 { scored.push(( SearchResult { entry: entry.clone(), source: ResultSource::Registry, validated: true, }, score, )); } } // Score cached discoveries let cache = self.discovery_cache.read().await; for entry in cache.iter() { let score = score_entry(entry, &tokens); if score > 0 { scored.push(( SearchResult { entry: entry.clone(), source: ResultSource::Discovered, validated: true, }, score, )); } } scored.sort_by_key(|b| std::cmp::Reverse(b.1)); scored.into_iter().map(|(r, _)| r).collect() } /// Look up an entry by exact name. /// /// NOTE: Prefer [`get_with_kind`] when a kind hint is available, to avoid /// returning the wrong entry when two entries share a name but differ in kind. pub async fn get(&self, name: &str) -> Option { if let Some(entry) = self.entries.iter().find(|e| e.name == name) { return Some(entry.clone()); } let cache = self.discovery_cache.read().await; cache.iter().find(|e| e.name == name).cloned() } /// Look up an entry by exact name, filtering by kind when provided. /// /// When `kind` is `Some(...)`, only returns an entry matching both name and /// kind — never falls back to a different kind. When `kind` is `None`, /// returns the first name match (same as [`get`]). pub async fn get_with_kind( &self, name: &str, kind: Option, ) -> Option { if let Some(kind) = kind { if let Some(entry) = self .entries .iter() .find(|e| e.name == name && e.kind == kind) { return Some(entry.clone()); } let cache = self.discovery_cache.read().await; if let Some(entry) = cache.iter().find(|e| e.name == name && e.kind == kind) { return Some(entry.clone()); } // Kind was specified but no entry matches — don't fall back to a // different kind, as that would silently misroute the install. return None; } self.get(name).await } /// Return all registry entries (builtins + cached discoveries). pub async fn all_entries(&self) -> Vec { let mut entries = self.entries.clone(); let cache = self.discovery_cache.read().await; for entry in cache.iter() { if !entries .iter() .any(|e| e.name == entry.name && e.kind == entry.kind) { entries.push(entry.clone()); } } entries } /// Add discovered entries to the cache. pub async fn cache_discovered(&self, entries: Vec) { let mut cache = self.discovery_cache.write().await; for entry in entries { // Deduplicate by (name, kind) — same pair as new_with_catalog() if !cache .iter() .any(|e| e.name == entry.name && e.kind == entry.kind) { cache.push(entry); } } } } impl Default for ExtensionRegistry { fn default() -> Self { Self::new() } } /// Score an entry against search tokens. Higher = better match. fn score_entry(entry: &RegistryEntry, tokens: &[String]) -> u32 { let mut score = 0u32; let name_lower = entry.name.to_lowercase(); let display_lower = entry.display_name.to_lowercase(); let desc_lower = entry.description.to_lowercase(); let keywords_lower: Vec = entry.keywords.iter().map(|k| k.to_lowercase()).collect(); for token in tokens { // Exact name match is the strongest signal if name_lower == *token { score += 100; } else if name_lower.contains(token.as_str()) { score += 50; } // Display name match if display_lower.contains(token.as_str()) { score += 30; } // Keyword match for kw in &keywords_lower { if kw == token { score += 40; } else if kw.contains(token.as_str()) { score += 20; } } // Description match (weakest signal) if desc_lower.contains(token.as_str()) { score += 10; } } score } /// Well-known extensions that ship with ironclaw. /// /// If `relay_url` is provided, a channel-relay Slack entry is included in the list. /// Pass `None` when the relay is not configured. pub fn builtin_entries() -> Vec { builtin_entries_with_relay(std::env::var("CHANNEL_RELAY_URL").ok()) } /// Well-known extensions, with an optional relay URL for the channel-relay entry. /// /// MCP server entries are loaded from `registry/mcp-servers/*.json` via the catalog /// system. Only runtime-dependent entries (like channel-relay) remain here. pub fn builtin_entries_with_relay(relay_url: Option) -> Vec { let mut entries = vec![]; // Conditionally add channel-relay entries when relay URL is configured if let Some(relay_url) = relay_url { entries.push(RegistryEntry { name: crate::channels::relay::DEFAULT_RELAY_NAME.to_string(), display_name: "Slack".to_string(), kind: ExtensionKind::ChannelRelay, description: "Connect Slack workspace via channel relay".to_string(), keywords: vec![ "slack".into(), "chat".into(), "messaging".into(), "relay".into(), ], source: ExtensionSource::ChannelRelay { relay_url }, fallback_source: None, auth_hint: AuthHint::ChannelRelayOAuth, version: None, }); } entries } #[cfg(test)] mod tests { use crate::extensions::registry::{ExtensionRegistry, score_entry}; use crate::extensions::{AuthHint, ExtensionKind, ExtensionSource, RegistryEntry}; #[test] fn test_score_exact_name_match() { let entry = RegistryEntry { name: "notion".to_string(), display_name: "Notion".to_string(), kind: ExtensionKind::McpServer, description: "Workspace tool".to_string(), keywords: vec!["notes".into()], source: ExtensionSource::McpUrl { url: "https://example.com".to_string(), }, fallback_source: None, auth_hint: AuthHint::Dcr, version: None, }; let score = score_entry(&entry, &["notion".to_string()]); assert!( score >= 100, "Exact name match should score >= 100, got {}", score ); } #[test] fn test_score_partial_name_match() { let entry = RegistryEntry { name: "google-calendar".to_string(), display_name: "Google Calendar".to_string(), kind: ExtensionKind::McpServer, description: "Calendar management".to_string(), keywords: vec!["events".into()], source: ExtensionSource::McpUrl { url: "https://example.com".to_string(), }, fallback_source: None, auth_hint: AuthHint::Dcr, version: None, }; let score = score_entry(&entry, &["calendar".to_string()]); assert!( score > 0, "Partial name match should score > 0, got {}", score ); } #[test] fn test_score_keyword_match() { let entry = RegistryEntry { name: "notion".to_string(), display_name: "Notion".to_string(), kind: ExtensionKind::McpServer, description: "Workspace tool".to_string(), keywords: vec!["wiki".into(), "notes".into()], source: ExtensionSource::McpUrl { url: "https://example.com".to_string(), }, fallback_source: None, auth_hint: AuthHint::Dcr, version: None, }; let score = score_entry(&entry, &["wiki".to_string()]); assert!( score >= 40, "Exact keyword match should score >= 40, got {}", score ); } #[test] fn test_score_no_match() { let entry = RegistryEntry { name: "notion".to_string(), display_name: "Notion".to_string(), kind: ExtensionKind::McpServer, description: "Workspace tool".to_string(), keywords: vec!["notes".into()], source: ExtensionSource::McpUrl { url: "https://example.com".to_string(), }, fallback_source: None, auth_hint: AuthHint::Dcr, version: None, }; let score = score_entry(&entry, &["xyzfoobar".to_string()]); assert_eq!(score, 0, "No match should score 0"); } /// Helper to create a registry with catalog entries (MCP servers come from catalog now). fn registry_with_catalog() -> ExtensionRegistry { let catalog = crate::registry::catalog::RegistryCatalog::load_or_embedded() .expect("catalog should load"); let catalog_entries: Vec = catalog .all() .iter() .filter_map(|m| m.to_registry_entry()) .collect(); ExtensionRegistry::new_with_catalog(catalog_entries) } #[tokio::test] async fn test_search_returns_sorted() { let registry = registry_with_catalog(); let results = registry.search("notion").await; assert!(!results.is_empty(), "Should find notion in registry"); assert_eq!(results[0].entry.name, "notion"); } #[tokio::test] async fn test_search_empty_query_returns_all() { let registry = registry_with_catalog(); let results = registry.search("").await; assert!(results.len() > 5, "Empty query should return all entries"); } #[tokio::test] async fn test_search_by_keyword() { let registry = registry_with_catalog(); let results = registry.search("issues tickets").await; assert!( !results.is_empty(), "Should find entries matching 'issues tickets'" ); // Linear should be near the top since it has both keywords let linear_pos = results.iter().position(|r| r.entry.name == "linear"); assert!(linear_pos.is_some(), "Linear should appear in results"); } #[tokio::test] async fn test_get_exact_name() { let registry = registry_with_catalog(); let entry = registry.get("notion").await; assert!(entry.is_some()); assert_eq!(entry.unwrap().display_name, "Notion"); let missing = registry.get("nonexistent").await; assert!(missing.is_none()); } #[tokio::test] async fn test_cache_discovered() { let registry = ExtensionRegistry::new(); let discovered = RegistryEntry { name: "custom-mcp".to_string(), display_name: "Custom MCP".to_string(), kind: ExtensionKind::McpServer, description: "A custom MCP server".to_string(), keywords: vec![], source: ExtensionSource::McpUrl { url: "https://custom.example.com".to_string(), }, fallback_source: None, auth_hint: AuthHint::Dcr, version: None, }; registry.cache_discovered(vec![discovered]).await; let entry = registry.get("custom-mcp").await; assert!(entry.is_some()); let results = registry.search("custom").await; assert!(!results.is_empty()); } #[tokio::test] async fn test_cache_deduplication() { let registry = ExtensionRegistry::new(); let entry = RegistryEntry { name: "dup".to_string(), display_name: "Dup".to_string(), kind: ExtensionKind::McpServer, description: "Test".to_string(), keywords: vec![], source: ExtensionSource::McpUrl { url: "https://example.com".to_string(), }, fallback_source: None, auth_hint: AuthHint::None, version: None, }; registry.cache_discovered(vec![entry.clone()]).await; registry.cache_discovered(vec![entry]).await; let results = registry.search("dup").await; assert_eq!(results.len(), 1, "Should not duplicate cached entries"); } #[tokio::test] async fn test_new_with_catalog() { let catalog_entries = vec![ RegistryEntry { name: "telegram".to_string(), display_name: "Telegram".to_string(), kind: ExtensionKind::WasmChannel, description: "Telegram Bot API channel".to_string(), keywords: vec!["messaging".into(), "bot".into()], source: ExtensionSource::WasmBuildable { source_dir: "channels-src/telegram".to_string(), build_dir: Some("channels-src/telegram".to_string()), crate_name: Some("telegram-channel".to_string()), }, fallback_source: None, auth_hint: AuthHint::CapabilitiesAuth, version: None, }, // Two entries with same name but different kinds should coexist RegistryEntry { name: "dual-ext".to_string(), display_name: "Dual MCP".to_string(), kind: ExtensionKind::McpServer, description: "Dual extension MCP server".to_string(), keywords: vec!["messaging".into()], source: ExtensionSource::McpUrl { url: "https://mcp.example.com".to_string(), }, fallback_source: None, auth_hint: AuthHint::Dcr, version: None, }, RegistryEntry { name: "dual-ext".to_string(), display_name: "Dual WASM".to_string(), kind: ExtensionKind::WasmTool, description: "Dual extension WASM tool".to_string(), keywords: vec!["messaging".into()], source: ExtensionSource::WasmBuildable { source_dir: "tools-src/dual".to_string(), build_dir: Some("tools-src/dual".to_string()), crate_name: Some("dual-tool".to_string()), }, fallback_source: None, auth_hint: AuthHint::CapabilitiesAuth, version: None, }, ]; let registry = ExtensionRegistry::new_with_catalog(catalog_entries); // Should find the new telegram entry let results = registry.search("telegram").await; assert!(!results.is_empty(), "Should find telegram from catalog"); assert_eq!(results[0].entry.name, "telegram"); // Should have both MCP and WASM entries with the same name let results = registry.search("dual-ext").await; let has_mcp = results .iter() .any(|r| r.entry.name == "dual-ext" && r.entry.kind == ExtensionKind::McpServer); let has_wasm = results .iter() .any(|r| r.entry.name == "dual-ext" && r.entry.kind == ExtensionKind::WasmTool); assert!(has_mcp, "Should have MCP dual-ext"); assert!(has_wasm, "Should have WASM dual-ext"); } #[tokio::test] async fn test_new_with_catalog_dedup_same_kind() { // When two catalog entries share name AND kind, only the first should be kept let catalog_entries = vec![ RegistryEntry { name: "test-ext".to_string(), display_name: "Test First".to_string(), kind: ExtensionKind::McpServer, description: "First entry".to_string(), keywords: vec![], source: ExtensionSource::McpUrl { url: "https://first.example.com".to_string(), }, fallback_source: None, auth_hint: AuthHint::Dcr, version: None, }, RegistryEntry { name: "test-ext".to_string(), display_name: "Test Duplicate".to_string(), kind: ExtensionKind::McpServer, // same kind description: "Should be skipped".to_string(), keywords: vec![], source: ExtensionSource::McpUrl { url: "https://second.example.com".to_string(), }, fallback_source: None, auth_hint: AuthHint::Dcr, version: None, }, ]; let registry = ExtensionRegistry::new_with_catalog(catalog_entries); let entry = registry.get("test-ext").await; assert!(entry.is_some()); // Should be the first entry, not the duplicate assert_eq!(entry.unwrap().display_name, "Test First"); } #[tokio::test] async fn test_get_with_kind_resolves_collision() { // Two entries with the same name but different kinds (the telegram collision scenario) let catalog_entries = vec![ RegistryEntry { name: "telegram".to_string(), display_name: "Telegram Tool".to_string(), kind: ExtensionKind::WasmTool, description: "Telegram MTProto tool".to_string(), keywords: vec!["messaging".into()], source: ExtensionSource::WasmBuildable { source_dir: "tools-src/telegram".to_string(), build_dir: Some("tools-src/telegram".to_string()), crate_name: Some("telegram-tool".to_string()), }, fallback_source: None, auth_hint: AuthHint::CapabilitiesAuth, version: None, }, RegistryEntry { name: "telegram".to_string(), display_name: "Telegram Channel".to_string(), kind: ExtensionKind::WasmChannel, description: "Telegram Bot API channel".to_string(), keywords: vec!["messaging".into(), "bot".into()], source: ExtensionSource::WasmBuildable { source_dir: "channels-src/telegram".to_string(), build_dir: Some("channels-src/telegram".to_string()), crate_name: Some("telegram-channel".to_string()), }, fallback_source: None, auth_hint: AuthHint::CapabilitiesAuth, version: None, }, ]; let registry = ExtensionRegistry::new_with_catalog(catalog_entries); // Without kind hint, get() returns the first match (WasmTool) let entry = registry.get("telegram").await; assert!(entry.is_some()); assert_eq!(entry.unwrap().kind, ExtensionKind::WasmTool); // With kind hint for WasmChannel, get_with_kind() returns the channel entry let entry = registry .get_with_kind("telegram", Some(ExtensionKind::WasmChannel)) .await; assert!(entry.is_some()); let entry = entry.unwrap(); assert_eq!(entry.kind, ExtensionKind::WasmChannel); assert_eq!(entry.display_name, "Telegram Channel"); // With kind hint for WasmTool, get_with_kind() returns the tool entry let entry = registry .get_with_kind("telegram", Some(ExtensionKind::WasmTool)) .await; assert!(entry.is_some()); let entry = entry.unwrap(); assert_eq!(entry.kind, ExtensionKind::WasmTool); assert_eq!(entry.display_name, "Telegram Tool"); // Without kind hint (None), get_with_kind() falls back to first match let entry = registry.get_with_kind("telegram", None).await; assert!(entry.is_some()); assert_eq!(entry.unwrap().kind, ExtensionKind::WasmTool); // Kind mismatch: no McpServer named "telegram" exists — must return None, // not silently fall back to the WasmTool entry. let entry = registry .get_with_kind("telegram", Some(ExtensionKind::McpServer)) .await; assert!( entry.is_none(), "Should return None when kind doesn't match, not fall back to wrong kind" ); } #[tokio::test] async fn test_get_with_kind_discovery_cache() { let registry = ExtensionRegistry::new(); // Add two entries with the same name but different kinds to the discovery cache let tool_entry = RegistryEntry { name: "cached-ext".to_string(), display_name: "Cached Tool".to_string(), kind: ExtensionKind::WasmTool, description: "A cached tool".to_string(), keywords: vec![], source: ExtensionSource::WasmBuildable { source_dir: "tools-src/cached".to_string(), build_dir: None, crate_name: None, }, fallback_source: None, auth_hint: AuthHint::None, version: None, }; let channel_entry = RegistryEntry { name: "cached-ext".to_string(), display_name: "Cached Channel".to_string(), kind: ExtensionKind::WasmChannel, description: "A cached channel".to_string(), keywords: vec![], source: ExtensionSource::WasmBuildable { source_dir: "channels-src/cached".to_string(), build_dir: None, crate_name: None, }, fallback_source: None, auth_hint: AuthHint::None, version: None, }; registry .cache_discovered(vec![tool_entry, channel_entry]) .await; // Kind-aware lookup should find the channel in the cache let entry = registry .get_with_kind("cached-ext", Some(ExtensionKind::WasmChannel)) .await; assert!(entry.is_some()); assert_eq!(entry.unwrap().display_name, "Cached Channel"); // Kind-aware lookup should find the tool in the cache let entry = registry .get_with_kind("cached-ext", Some(ExtensionKind::WasmTool)) .await; assert!(entry.is_some()); assert_eq!(entry.unwrap().display_name, "Cached Tool"); } // Channel tests (telegram, slack, discord, whatsapp) require the embedded catalog // to be loaded via new_with_catalog(). See test_new_with_catalog for catalog coverage. // === QA Plan P2 - 2.4: Extension registry collision tests === #[tokio::test] async fn test_same_name_different_kind_both_discoverable() { // A WASM channel and WASM tool with the same name must coexist. let catalog_entries = vec![ RegistryEntry { name: "telegram".to_string(), display_name: "Telegram Channel".to_string(), kind: ExtensionKind::WasmChannel, description: "Telegram messaging channel".to_string(), keywords: vec!["messaging".into()], source: ExtensionSource::WasmBuildable { source_dir: "channels-src/telegram".to_string(), build_dir: None, crate_name: None, }, fallback_source: None, auth_hint: AuthHint::CapabilitiesAuth, version: None, }, RegistryEntry { name: "telegram".to_string(), display_name: "Telegram Tool".to_string(), kind: ExtensionKind::WasmTool, description: "Telegram API tool".to_string(), keywords: vec!["messaging".into()], source: ExtensionSource::WasmBuildable { source_dir: "tools-src/telegram".to_string(), build_dir: None, crate_name: None, }, fallback_source: None, auth_hint: AuthHint::CapabilitiesAuth, version: None, }, ]; let registry = ExtensionRegistry::new_with_catalog(catalog_entries); let all = registry.all_entries().await; // Both should exist since they have different kinds. let channel = all .iter() .find(|e| e.name == "telegram" && e.kind == ExtensionKind::WasmChannel); let tool = all .iter() .find(|e| e.name == "telegram" && e.kind == ExtensionKind::WasmTool); assert!(channel.is_some(), "Channel entry missing"); assert!(tool.is_some(), "Tool entry missing"); // Search should return both. let results = registry.search("telegram").await; let channel_hit = results .iter() .any(|r| r.entry.name == "telegram" && r.entry.kind == ExtensionKind::WasmChannel); let tool_hit = results .iter() .any(|r| r.entry.name == "telegram" && r.entry.kind == ExtensionKind::WasmTool); assert!(channel_hit, "Search should find channel"); assert!(tool_hit, "Search should find tool"); } #[tokio::test] async fn test_get_returns_first_match_regardless_of_kind() { // `get()` returns the first entry with a matching name. If a channel // and tool share a name, callers that need a specific kind should // filter by kind. let catalog_entries = vec![ RegistryEntry { name: "myext".to_string(), display_name: "MyExt Channel".to_string(), kind: ExtensionKind::WasmChannel, description: "Channel".to_string(), keywords: vec![], source: ExtensionSource::WasmBuildable { source_dir: "x".to_string(), build_dir: None, crate_name: None, }, fallback_source: None, auth_hint: AuthHint::None, version: None, }, RegistryEntry { name: "myext".to_string(), display_name: "MyExt Tool".to_string(), kind: ExtensionKind::WasmTool, description: "Tool".to_string(), keywords: vec![], source: ExtensionSource::WasmBuildable { source_dir: "y".to_string(), build_dir: None, crate_name: None, }, fallback_source: None, auth_hint: AuthHint::None, version: None, }, ]; let registry = ExtensionRegistry::new_with_catalog(catalog_entries); // get() is name-only, returns first match. let entry = registry.get("myext").await; assert!(entry.is_some()); // The first catalog entry added is the channel. assert_eq!(entry.unwrap().kind, ExtensionKind::WasmChannel); } #[test] fn test_builtin_entries_with_relay_none_excludes_relay() { let entries = super::builtin_entries_with_relay(None); assert!( !entries .iter() .any(|e| e.kind == ExtensionKind::ChannelRelay), "No ChannelRelay entry when relay URL is None" ); } #[test] fn test_builtin_entries_with_relay_some_includes_relay() { let entries = super::builtin_entries_with_relay(Some("http://relay.example.com".to_string())); let relay = entries .iter() .find(|e| e.kind == ExtensionKind::ChannelRelay); assert!(relay.is_some(), "ChannelRelay entry should be present"); if let ExtensionSource::ChannelRelay { relay_url } = &relay.unwrap().source { assert_eq!(relay_url, "http://relay.example.com"); } else { panic!("Expected ChannelRelay source"); } } }