use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::registry::embedded;
use crate::registry::manifest::{BundleDefinition, BundlesFile, ExtensionManifest, ManifestKind};
#[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,
},
#[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),
}
#[derive(Debug, Clone)]
pub struct RegistryCatalog {
manifests: HashMap<String, ExtensionManifest>,
bundles: HashMap<String, BundleDefinition>,
root: PathBuf,
}
impl RegistryCatalog {
pub fn find_dir() -> Option<PathBuf> {
if let Ok(cwd) = std::env::current_dir() {
let candidate = cwd.join("registry");
if candidate.is_dir() {
return Some(candidate);
}
}
if let Ok(exe) = std::env::current_exe()
&& let Some(parent) = exe.parent()
{
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();
}
}
}
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
}
pub fn load_or_embedded() -> Result<Self, RegistryError> {
if let Some(dir) = Self::find_dir() {
return Self::load(&dir);
}
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(),
})
}
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();
let tools_dir = registry_dir.join("tools");
if tools_dir.is_dir() {
Self::load_manifests_from_dir(&tools_dir, "tools", &mut manifests)?;
}
let channels_dir = registry_dir.join("channels");
if channels_dir.is_dir() {
Self::load_manifests_from_dir(&channels_dir, "channels", &mut manifests)?;
}
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)?;
}
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(())
}
pub fn root(&self) -> &Path {
&self.root
}
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
}
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
}
pub fn get(&self, name: &str) -> Option<&ExtensionManifest> {
if let Some(m) = self.manifests.get(name) {
return Some(m);
}
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 }
}
pub fn get_strict(&self, name: &str) -> Result<&ExtensionManifest, RegistryError> {
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,
})
}
}
}
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 }
}
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
}
pub fn get_bundle(&self, name: &str) -> Option<&BundleDefinition> {
self.bundles.get(name)
}
pub fn bundle_names(&self) -> Vec<&str> {
let mut names: Vec<_> = self.bundles.keys().map(|s| s.as_str()).collect();
names.sort();
names
}
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))
}
pub fn is_bundle(&self, name: &str) -> bool {
self.bundles.contains_key(name)
}
pub fn resolve(
&self,
name: &str,
) -> Result<(Vec<&ExtensionManifest>, Option<&BundleDefinition>), RegistryError> {
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)));
}
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); }
#[test]
fn test_get_by_name() {
let tmp = tempfile::tempdir().unwrap();
create_test_registry(tmp.path());
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
assert!(catalog.get("tools/slack").is_some());
assert!(catalog.get("mcp-servers/notion").is_some());
assert!(catalog.get("slack").is_some());
assert!(catalog.get("telegram").is_some());
assert!(catalog.get("notion").is_some());
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();
let (manifests, bundle) = catalog.resolve("slack").unwrap();
assert_eq!(manifests.len(), 1);
assert!(bundle.is_none());
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() {
let catalog = RegistryCatalog::load_or_embedded().unwrap();
assert!(!catalog.all().is_empty() || !catalog.bundle_names().is_empty());
}
#[test]
fn test_bundle_entries_resolve_against_real_registry() {
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
);
}
}
}