//! 目录索引与条目加载(Wave 1) //! //! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml`,对每个工具 //! 做完整 schema 校验(含危险命令拒载、版本号校验),最后返回 UI 侧的五字段 //! `CatalogEntry` 视图(`load_catalog`)或完整 `Adapter`(`load_adapters`)。 use std::fs; use std::path::Path; use serde::{Deserialize, Serialize}; use crate::error::AdapterError; use crate::schema::{Adapter, parse_adapter}; /// 目录索引(adapters/catalog.yaml) #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct Catalog { #[serde(rename = "catalog_version")] pub version: u32, pub tools: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct CatalogRef { pub id: String, pub file: String, } /// 目录条目(五字段视图,供 UI 展示) #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct CatalogEntry { pub id: String, pub name: String, #[serde(rename = "name_zh")] pub name_zh: String, pub vendor: String, /// available | watch pub status: String, } impl From for CatalogEntry { fn from(a: Adapter) -> Self { CatalogEntry { id: a.id, name: a.name, name_zh: a.name_zh, vendor: a.vendor, status: a.status, } } } /// 读取目录索引。 fn read_index>(adapters_dir: P) -> Result { let dir = adapters_dir.as_ref(); let catalog_text = fs::read_to_string(dir.join("catalog.yaml")) .map_err(|e| AdapterError::Io(format!("读取 catalog.yaml 失败: {e}")))?; serde_yaml::from_str(&catalog_text) .map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}"))) } /// 加载并校验全部工具适配器(完整 schema)。 pub fn load_adapters>(adapters_dir: P) -> Result, AdapterError> { let dir = adapters_dir.as_ref(); let catalog = read_index(dir)?; let mut adapters = Vec::with_capacity(catalog.tools.len()); for r in &catalog.tools { let text = fs::read_to_string(dir.join(&r.file)) .map_err(|e| AdapterError::Io(format!("读取 {} 失败: {e}", r.file)))?; let adapter = parse_adapter(&text) .map_err(|e| AdapterError::Parse(format!("{} 校验未通过: {e}", r.file)))?; if adapter.id != r.id { return Err(AdapterError::Validation(format!( "索引 id 与文件内 id 不一致: 索引={} 文件={}", r.id, adapter.id ))); } adapters.push(adapter); } Ok(adapters) } /// 加载目录并返回 UI 五字段视图(内部已做完整 schema 校验)。 pub fn load_catalog>(adapters_dir: P) -> Result, AdapterError> { Ok(load_adapters(adapters_dir)?.into_iter().map(CatalogEntry::from).collect()) } #[cfg(test)] mod tests { use super::*; use crate::error::AdapterError; #[test] fn parses_minimal_tool_yaml() { let yaml = "id: codex\nname: Codex CLI\nname_zh: Codex CLI\nvendor: OpenAI\nstatus: available\n"; let entry: CatalogEntry = serde_yaml::from_str(yaml).expect("应能解析"); assert_eq!(entry.id, "codex"); assert_eq!(entry.name_zh, "Codex CLI"); assert_eq!(entry.vendor, "OpenAI"); assert_eq!(entry.status, "available"); } #[test] fn loads_real_catalog_from_repo() { // 以真实 adapters/ 目录做集成测试(相对本 crate 位于 ../../adapters) let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters"); let entries = load_catalog(&dir).expect("真实目录应可加载并通过校验"); assert_eq!(entries.len(), 14, "第一批应为 14 个工具"); for e in &entries { assert!(!e.id.is_empty()); assert!(!e.name.is_empty()); assert!(!e.name_zh.is_empty()); assert!(!e.vendor.is_empty()); } let ids: Vec<&str> = entries.iter().map(|e| e.id.as_str()).collect(); for want in ["codex", "claude-code", "gemini", "copilot", "kimi", "qwen", "codebuddy", "opencode", "crush", "goose", "aider", "cursor", "cline", "warp"] { assert!(ids.contains(&want), "目录应包含 {want}"); } } /// 加载器拒载危险适配器(含 shell 元字符)并给中文错误。 #[test] fn loader_rejects_dangerous_adapter() { let dir = std::env::temp_dir().join(format!("agentdock-adapters-danger-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(dir.join("tools")).unwrap(); fs::write( dir.join("catalog.yaml"), "catalog_version: 1\ntools:\n - id: evil\n file: tools/evil.yaml\n", ) .unwrap(); fs::write( dir.join("tools/evil.yaml"), "id: evil\nname: Evil\nname_zh: Evil\nvendor: X\nstatus: available\ninstall:\n channels:\n - id: official_script\n command: [\"curl\", \"x | sh\"]\n", ) .unwrap(); match load_adapters(&dir) { Err(AdapterError::Parse(m)) => assert!(m.contains('|'), "错误应含元字符: {m}"), other => panic!("应因危险命令拒载,实际 {other:?}"), } let _ = fs::remove_dir_all(&dir); } /// 加载器校验版本号:非法 adapter_version 拒载。 #[test] fn loader_rejects_bad_version() { let dir = std::env::temp_dir().join(format!("agentdock-adapters-ver-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(dir.join("tools")).unwrap(); fs::write( dir.join("catalog.yaml"), "catalog_version: 1\ntools:\n - id: bad\n file: tools/bad.yaml\n", ) .unwrap(); fs::write( dir.join("tools/bad.yaml"), "id: bad\nname: Bad\nname_zh: Bad\nvendor: X\nstatus: available\nadapter_version: not-semver\n", ) .unwrap(); assert!(matches!(load_adapters(&dir), Err(AdapterError::Parse(_)))); let _ = fs::remove_dir_all(&dir); } }