Files
openfang/crates/openfang-extensions/src/registry.rs
iven 92e5def702
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
初始化提交
2026-03-01 16:24:24 +08:00

361 lines
12 KiB
Rust

//! Integration Registry — manages bundled + installed integration templates.
//!
//! Loads 25 bundled MCP server templates at compile time, merges with user's
//! installed state from `~/.openfang/integrations.toml`, and converts installed
//! integrations to `McpServerConfigEntry` for kernel consumption.
use crate::{
ExtensionError, ExtensionResult, InstalledIntegration, IntegrationCategory, IntegrationInfo,
IntegrationStatus, IntegrationTemplate, IntegrationsFile,
};
use openfang_types::config::{McpServerConfigEntry, McpTransportEntry};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
/// The integration registry — holds all known templates and install state.
pub struct IntegrationRegistry {
/// All known templates (bundled + custom).
templates: HashMap<String, IntegrationTemplate>,
/// Current installed state.
installed: HashMap<String, InstalledIntegration>,
/// Path to integrations.toml.
integrations_path: PathBuf,
}
impl IntegrationRegistry {
/// Create a new registry with no templates.
pub fn new(home_dir: &Path) -> Self {
Self {
templates: HashMap::new(),
installed: HashMap::new(),
integrations_path: home_dir.join("integrations.toml"),
}
}
/// Load bundled templates (compile-time embedded). Returns count loaded.
pub fn load_bundled(&mut self) -> usize {
let bundled = crate::bundled::bundled_integrations();
let count = bundled.len();
for (id, toml_content) in bundled {
match toml::from_str::<IntegrationTemplate>(toml_content) {
Ok(template) => {
self.templates.insert(id.to_string(), template);
}
Err(e) => {
warn!("Failed to parse bundled integration '{}': {}", id, e);
}
}
}
debug!("Loaded {count} bundled integration template(s)");
count
}
/// Load installed state from integrations.toml.
pub fn load_installed(&mut self) -> ExtensionResult<usize> {
if !self.integrations_path.exists() {
return Ok(0);
}
let content = std::fs::read_to_string(&self.integrations_path)?;
let file: IntegrationsFile =
toml::from_str(&content).map_err(|e| ExtensionError::TomlParse(e.to_string()))?;
let count = file.installed.len();
for entry in file.installed {
self.installed.insert(entry.id.clone(), entry);
}
info!("Loaded {count} installed integration(s)");
Ok(count)
}
/// Save installed state to integrations.toml.
pub fn save_installed(&self) -> ExtensionResult<()> {
let file = IntegrationsFile {
installed: self.installed.values().cloned().collect(),
};
let content =
toml::to_string_pretty(&file).map_err(|e| ExtensionError::TomlParse(e.to_string()))?;
if let Some(parent) = self.integrations_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&self.integrations_path, content)?;
Ok(())
}
/// Get a template by ID.
pub fn get_template(&self, id: &str) -> Option<&IntegrationTemplate> {
self.templates.get(id)
}
/// Get an installed record by ID.
pub fn get_installed(&self, id: &str) -> Option<&InstalledIntegration> {
self.installed.get(id)
}
/// Check if an integration is installed.
pub fn is_installed(&self, id: &str) -> bool {
self.installed.contains_key(id)
}
/// Mark an integration as installed.
pub fn install(&mut self, entry: InstalledIntegration) -> ExtensionResult<()> {
if self.installed.contains_key(&entry.id) {
return Err(ExtensionError::AlreadyInstalled(entry.id.clone()));
}
self.installed.insert(entry.id.clone(), entry);
self.save_installed()
}
/// Remove an installed integration.
pub fn uninstall(&mut self, id: &str) -> ExtensionResult<()> {
if self.installed.remove(id).is_none() {
return Err(ExtensionError::NotInstalled(id.to_string()));
}
self.save_installed()
}
/// Enable/disable an installed integration.
pub fn set_enabled(&mut self, id: &str, enabled: bool) -> ExtensionResult<()> {
let entry = self
.installed
.get_mut(id)
.ok_or_else(|| ExtensionError::NotInstalled(id.to_string()))?;
entry.enabled = enabled;
self.save_installed()
}
/// List all templates.
pub fn list_templates(&self) -> Vec<&IntegrationTemplate> {
let mut templates: Vec<_> = self.templates.values().collect();
templates.sort_by(|a, b| a.id.cmp(&b.id));
templates
}
/// List templates by category.
pub fn list_by_category(&self, category: &IntegrationCategory) -> Vec<&IntegrationTemplate> {
self.templates
.values()
.filter(|t| &t.category == category)
.collect()
}
/// Search templates by query (matches id, name, description, tags).
pub fn search(&self, query: &str) -> Vec<&IntegrationTemplate> {
let q = query.to_lowercase();
self.templates
.values()
.filter(|t| {
t.id.to_lowercase().contains(&q)
|| t.name.to_lowercase().contains(&q)
|| t.description.to_lowercase().contains(&q)
|| t.tags.iter().any(|tag| tag.to_lowercase().contains(&q))
})
.collect()
}
/// Get combined info for all integrations (template + install state).
pub fn list_all_info(&self) -> Vec<IntegrationInfo> {
self.templates
.values()
.map(|t| {
let installed = self.installed.get(&t.id);
let status = match installed {
Some(inst) if !inst.enabled => IntegrationStatus::Disabled,
Some(_) => IntegrationStatus::Ready,
None => IntegrationStatus::Available,
};
IntegrationInfo {
template: t.clone(),
status,
installed: installed.cloned(),
tool_count: 0,
}
})
.collect()
}
/// Convert all enabled installed integrations to MCP server config entries.
/// These can be merged into the kernel's MCP server list.
pub fn to_mcp_configs(&self) -> Vec<McpServerConfigEntry> {
self.installed
.values()
.filter(|inst| inst.enabled)
.filter_map(|inst| {
let template = self.templates.get(&inst.id)?;
let transport = match &template.transport {
crate::McpTransportTemplate::Stdio { command, args } => {
McpTransportEntry::Stdio {
command: command.clone(),
args: args.clone(),
}
}
crate::McpTransportTemplate::Sse { url } => {
McpTransportEntry::Sse { url: url.clone() }
}
};
let env: Vec<String> = template
.required_env
.iter()
.map(|e| e.name.clone())
.collect();
Some(McpServerConfigEntry {
name: inst.id.clone(),
transport,
timeout_secs: 30,
env,
})
})
.collect()
}
/// Get the path to integrations.toml.
pub fn integrations_path(&self) -> &Path {
&self.integrations_path
}
/// Total template count.
pub fn template_count(&self) -> usize {
self.templates.len()
}
/// Total installed count.
pub fn installed_count(&self) -> usize {
self.installed.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_load_bundled() {
let dir = tempfile::tempdir().unwrap();
let mut reg = IntegrationRegistry::new(dir.path());
let count = reg.load_bundled();
assert_eq!(count, 25);
assert_eq!(reg.template_count(), 25);
}
#[test]
fn registry_get_template() {
let dir = tempfile::tempdir().unwrap();
let mut reg = IntegrationRegistry::new(dir.path());
reg.load_bundled();
let gh = reg.get_template("github").unwrap();
assert_eq!(gh.name, "GitHub");
assert_eq!(gh.category, IntegrationCategory::DevTools);
}
#[test]
fn registry_search() {
let dir = tempfile::tempdir().unwrap();
let mut reg = IntegrationRegistry::new(dir.path());
reg.load_bundled();
let results = reg.search("search");
assert!(results.len() >= 2); // brave-search, exa-search
}
#[test]
fn registry_install_uninstall() {
let dir = tempfile::tempdir().unwrap();
let mut reg = IntegrationRegistry::new(dir.path());
reg.load_bundled();
let entry = InstalledIntegration {
id: "github".to_string(),
installed_at: chrono::Utc::now(),
enabled: true,
oauth_provider: None,
config: HashMap::new(),
};
reg.install(entry).unwrap();
assert!(reg.is_installed("github"));
assert_eq!(reg.installed_count(), 1);
// Double install should fail
let entry2 = InstalledIntegration {
id: "github".to_string(),
installed_at: chrono::Utc::now(),
enabled: true,
oauth_provider: None,
config: HashMap::new(),
};
assert!(reg.install(entry2).is_err());
reg.uninstall("github").unwrap();
assert!(!reg.is_installed("github"));
}
#[test]
fn registry_to_mcp_configs() {
let dir = tempfile::tempdir().unwrap();
let mut reg = IntegrationRegistry::new(dir.path());
reg.load_bundled();
let entry = InstalledIntegration {
id: "github".to_string(),
installed_at: chrono::Utc::now(),
enabled: true,
oauth_provider: None,
config: HashMap::new(),
};
reg.install(entry).unwrap();
let configs = reg.to_mcp_configs();
assert_eq!(configs.len(), 1);
assert_eq!(configs[0].name, "github");
}
#[test]
fn registry_save_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut reg = IntegrationRegistry::new(dir.path());
reg.load_bundled();
let entry = InstalledIntegration {
id: "notion".to_string(),
installed_at: chrono::Utc::now(),
enabled: true,
oauth_provider: None,
config: HashMap::new(),
};
reg.install(entry).unwrap();
// Load from same path
let mut reg2 = IntegrationRegistry::new(dir.path());
reg2.load_bundled();
let count = reg2.load_installed().unwrap();
assert_eq!(count, 1);
assert!(reg2.is_installed("notion"));
}
#[test]
fn registry_list_by_category() {
let dir = tempfile::tempdir().unwrap();
let mut reg = IntegrationRegistry::new(dir.path());
reg.load_bundled();
let devtools = reg.list_by_category(&IntegrationCategory::DevTools);
assert_eq!(devtools.len(), 6);
}
#[test]
fn registry_set_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut reg = IntegrationRegistry::new(dir.path());
reg.load_bundled();
let entry = InstalledIntegration {
id: "github".to_string(),
installed_at: chrono::Utc::now(),
enabled: true,
oauth_provider: None,
config: HashMap::new(),
};
reg.install(entry).unwrap();
reg.set_enabled("github", false).unwrap();
let configs = reg.to_mcp_configs();
assert!(configs.is_empty()); // disabled = not in MCP configs
}
}