Files
optimclaw/src/extensions/registry.rs
T
c916069dd2 refactor(registry): move MCP servers from code to JSON manifests (#1144)
* refactor(registry): move MCP server entries from code to JSON manifests

Move 8 hardcoded MCP server RegistryEntry structs from
builtin_entries() into data-driven JSON files under
registry/mcp-servers/, matching the existing pattern used by
tools and channels. Exclude the GitHub MCP entry which conflicts
with the WASM GitHub tool's OAuth flow.

Extend ManifestKind with McpServer, make version/source optional
on ExtensionManifest (MCP servers don't need them), and add
url/auth fields for MCP-specific config. Update build.rs,
embedded catalog, catalog loader, installer, and CLI display
to handle the new kind and optional fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(registry): address PR review — add missing slack-mcp, remove .expect(), fix fmt

- Add missing slack-mcp.json (was dropped during migration)
- Remove production .expect() in get_strict(), replace with .ok_or_else()
- Clean up unwrap_or_default() in key_for() to use .next() directly
- Log warning for MCP manifests missing url field instead of silent empty
- Run cargo fmt to fix formatting diffs

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* ci: re-trigger CI with correct base branch (staging)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(ci): improve no-panics check to properly exclude test modules

The grep-based filter only excluded lines literally containing
#[cfg(test)], #[test], or 'mod tests' — not lines *inside* test
modules. Use awk to track hunk context from diff @@ headers and
skip all added lines within test module hunks.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(registry): remove slack-mcp MCP entry (conflicts with WASM slack tool)

Remove slack-mcp.json alongside the already-excluded github MCP
entry — both conflict with existing WASM tools of the same name.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(registry): address re-review — skip invalid MCP entries, fix install order

- to_registry_entry() now returns Option<RegistryEntry>; MCP manifests
  missing a url field are skipped with a warning instead of creating
  broken entries with empty URLs
- Move McpServer early-return before require_source() in install paths
  so the error message is clear ("cannot install MCP servers") rather
  than the misleading "missing source spec"
- Add test for MCP manifest with missing URL returning None

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-14 18:59:55 +00:00

848 lines
31 KiB
Rust

//! 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<RegistryEntry>,
/// Cached entries from online discovery (session-lived).
discovery_cache: RwLock<Vec<RegistryEntry>>,
}
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<RegistryEntry>) -> 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<SearchResult> {
let tokens: Vec<String> = 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<RegistryEntry> {
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<ExtensionKind>,
) -> Option<RegistryEntry> {
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<RegistryEntry> {
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<RegistryEntry>) {
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<String> = 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<RegistryEntry> {
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<String>) -> Vec<RegistryEntry> {
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<RegistryEntry> = 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");
}
}
}