169 lines
6.2 KiB
Rust
169 lines
6.2 KiB
Rust
//! 目录索引与条目加载(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<CatalogRef>,
|
||
}
|
||
|
||
#[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<Adapter> 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<P: AsRef<Path>>(adapters_dir: P) -> Result<Catalog, AdapterError> {
|
||
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<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<Adapter>, 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<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, 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);
|
||
}
|
||
}
|