mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
* 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]>
766 lines
25 KiB
Rust
766 lines
25 KiB
Rust
//! 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<RegistryError>,
|
|
},
|
|
|
|
#[error("Artifact install and source fallback both failed for '{name}'.")]
|
|
InstallFallbackFailed {
|
|
name: String,
|
|
artifact_error: Box<RegistryError>,
|
|
source_error: Box<RegistryError>,
|
|
},
|
|
|
|
#[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 "<kind>/<name>" (e.g. "tools/github").
|
|
manifests: HashMap<String, ExtensionManifest>,
|
|
|
|
/// Bundle definitions from `_bundles.json`.
|
|
bundles: HashMap<String, BundleDefinition>,
|
|
|
|
/// 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<PathBuf> {
|
|
// 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<Self, RegistryError> {
|
|
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<Self, RegistryError> {
|
|
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<String, ExtensionManifest>,
|
|
) -> 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<ManifestKind>, 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<String> {
|
|
if self.manifests.contains_key(name) {
|
|
return Some(name.to_string());
|
|
}
|
|
|
|
let matches: Vec<String> = ["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<String>), 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
|
|
);
|
|
}
|
|
}
|
|
}
|