mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-03-27 21:39:58 +08:00
Add mcp/opencode.rs with format conversion between CC Switch and OpenCode: - stdio ↔ local type conversion - command+args ↔ command array format - env ↔ environment field mapping - sse/http ↔ remote type conversion Public API: - sync_enabled_to_opencode: Batch sync all enabled servers - sync_single_server_to_opencode: Sync individual server - remove_server_from_opencode: Remove from live config - import_from_opencode: Import servers from OpenCode config Also fix test files to include new opencode field in McpApps struct. All 4 unit tests pass for format conversion.
563 lines
18 KiB
Rust
563 lines
18 KiB
Rust
use std::collections::HashMap;
|
||
use std::fs;
|
||
|
||
use serde_json::json;
|
||
|
||
use cc_switch_lib::{
|
||
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
|
||
AppType, McpApps, McpServer, McpService, MultiAppConfig,
|
||
};
|
||
|
||
#[path = "support.rs"]
|
||
mod support;
|
||
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
|
||
|
||
#[test]
|
||
fn import_default_config_claude_persists_provider() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let settings_path = get_claude_settings_path();
|
||
if let Some(parent) = settings_path.parent() {
|
||
fs::create_dir_all(parent).expect("create claude settings dir");
|
||
}
|
||
let settings = json!({
|
||
"env": {
|
||
"ANTHROPIC_AUTH_TOKEN": "test-key",
|
||
"ANTHROPIC_BASE_URL": "https://api.test"
|
||
}
|
||
});
|
||
fs::write(
|
||
&settings_path,
|
||
serde_json::to_string_pretty(&settings).expect("serialize settings"),
|
||
)
|
||
.expect("seed claude settings.json");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
config.ensure_app(&AppType::Claude);
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
import_default_config_test_hook(&state, AppType::Claude)
|
||
.expect("import default config succeeds");
|
||
|
||
// 验证内存状态
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Claude.as_str())
|
||
.expect("get all providers");
|
||
let current_id = state
|
||
.db
|
||
.get_current_provider(AppType::Claude.as_str())
|
||
.expect("get current provider");
|
||
assert_eq!(current_id.as_deref(), Some("default"));
|
||
let default_provider = providers.get("default").expect("default provider");
|
||
assert_eq!(
|
||
default_provider.settings_config, settings,
|
||
"default provider should capture live settings"
|
||
);
|
||
|
||
// 验证数据已持久化到数据库(v3.7.0+ 使用 SQLite 而非 config.json)
|
||
let db_path = home.join(".cc-switch").join("cc-switch.db");
|
||
assert!(
|
||
db_path.exists(),
|
||
"importing default config should persist to cc-switch.db"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn import_default_config_without_live_file_returns_error() {
|
||
use support::create_test_state;
|
||
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
|
||
let err = import_default_config_test_hook(&state, AppType::Claude)
|
||
.expect_err("missing live file should error");
|
||
match err {
|
||
AppError::Localized { zh, .. } => assert!(
|
||
zh.contains("Claude Code 配置文件不存在"),
|
||
"unexpected error message: {zh}"
|
||
),
|
||
AppError::Message(msg) => assert!(
|
||
msg.contains("Claude Code 配置文件不存在"),
|
||
"unexpected error message: {msg}"
|
||
),
|
||
other => panic!("unexpected error variant: {other:?}"),
|
||
}
|
||
|
||
// 使用数据库架构,不再检查 config.json
|
||
// 失败的导入不应该向数据库写入任何供应商
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Claude.as_str())
|
||
.expect("get all providers");
|
||
assert!(
|
||
providers.is_empty(),
|
||
"failed import should not create any providers in database"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn import_mcp_from_claude_creates_config_and_enables_servers() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let mcp_path = get_claude_mcp_path();
|
||
let claude_json = json!({
|
||
"mcpServers": {
|
||
"echo": {
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}
|
||
}
|
||
});
|
||
fs::write(
|
||
&mcp_path,
|
||
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
|
||
)
|
||
.expect("seed ~/.claude.json");
|
||
|
||
let config = MultiAppConfig::default();
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
let changed = McpService::import_from_claude(&state).expect("import mcp from claude succeeds");
|
||
assert!(
|
||
changed > 0,
|
||
"import should report inserted or normalized entries"
|
||
);
|
||
|
||
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
|
||
let entry = servers
|
||
.get("echo")
|
||
.expect("server imported into unified structure");
|
||
assert!(
|
||
entry.apps.claude,
|
||
"imported server should have Claude app enabled"
|
||
);
|
||
|
||
// 验证数据已持久化到数据库
|
||
let db_path = home.join(".cc-switch").join("cc-switch.db");
|
||
assert!(
|
||
db_path.exists(),
|
||
"state.save should persist to cc-switch.db when changes detected"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn import_mcp_from_claude_invalid_json_preserves_state() {
|
||
use support::create_test_state;
|
||
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let mcp_path = get_claude_mcp_path();
|
||
fs::write(&mcp_path, "{\"mcpServers\":") // 不完整 JSON
|
||
.expect("seed invalid ~/.claude.json");
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
|
||
let err =
|
||
McpService::import_from_claude(&state).expect_err("invalid json should bubble up error");
|
||
match err {
|
||
AppError::McpValidation(msg) => assert!(
|
||
msg.contains("解析 ~/.claude.json 失败"),
|
||
"unexpected error message: {msg}"
|
||
),
|
||
other => panic!("unexpected error variant: {other:?}"),
|
||
}
|
||
|
||
// 使用数据库架构,检查 MCP 服务器未被写入
|
||
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
|
||
assert!(
|
||
servers.is_empty(),
|
||
"failed import should not persist any MCP servers to database"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn set_mcp_enabled_for_codex_writes_live_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
// 创建 Codex 配置目录和文件
|
||
let codex_dir = home.join(".codex");
|
||
fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||
fs::write(
|
||
codex_dir.join("auth.json"),
|
||
r#"{"OPENAI_API_KEY":"test-key"}"#,
|
||
)
|
||
.expect("create auth.json");
|
||
fs::write(codex_dir.join("config.toml"), "").expect("create empty config.toml");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
config.ensure_app(&AppType::Codex);
|
||
|
||
// v3.7.0: 使用统一结构
|
||
config.mcp.servers = Some(HashMap::new());
|
||
config.mcp.servers.as_mut().unwrap().insert(
|
||
"codex-server".into(),
|
||
McpServer {
|
||
id: "codex-server".to_string(),
|
||
name: "Codex Server".to_string(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}),
|
||
apps: McpApps {
|
||
claude: false,
|
||
codex: false, // 初始未启用
|
||
gemini: false,
|
||
opencode: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
);
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
// v3.7.0: 使用 toggle_app 替代 set_enabled
|
||
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
|
||
.expect("toggle_app should succeed");
|
||
|
||
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
|
||
let entry = servers.get("codex-server").expect("codex server exists");
|
||
assert!(
|
||
entry.apps.codex,
|
||
"server should have Codex app enabled after toggle"
|
||
);
|
||
|
||
let toml_path = cc_switch_lib::get_codex_config_path();
|
||
assert!(
|
||
toml_path.exists(),
|
||
"enabling server should trigger sync to ~/.codex/config.toml"
|
||
);
|
||
let toml_text = fs::read_to_string(&toml_path).expect("read codex config");
|
||
assert!(
|
||
toml_text.contains("codex-server"),
|
||
"codex config should include the enabled server definition"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn enabling_codex_mcp_skips_when_codex_dir_missing() {
|
||
use support::create_test_state;
|
||
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
// 确认 Codex 配置目录不存在(模拟“未安装/未运行过 Codex CLI”)
|
||
assert!(
|
||
!home.join(".codex").exists(),
|
||
"~/.codex should not exist in fresh test environment"
|
||
);
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
|
||
// 先插入一个未启用 Codex 的 MCP 服务器(避免 upsert 触发同步)
|
||
McpService::upsert_server(
|
||
&state,
|
||
McpServer {
|
||
id: "codex-server".to_string(),
|
||
name: "Codex Server".to_string(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}),
|
||
apps: McpApps {
|
||
claude: false,
|
||
codex: false,
|
||
gemini: false,
|
||
opencode: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
)
|
||
.expect("insert server without syncing");
|
||
|
||
// 启用 Codex:目录缺失时应跳过写入(不创建 ~/.codex/config.toml)
|
||
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
|
||
.expect("toggle codex should succeed even when ~/.codex is missing");
|
||
|
||
assert!(
|
||
!home.join(".codex").exists(),
|
||
"~/.codex should still not exist after skipped sync"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
// 模拟 Claude 已安装/已初始化:存在 ~/.claude 目录
|
||
fs::create_dir_all(home.join(".claude")).expect("create ~/.claude dir");
|
||
|
||
// 先创建一个启用 Claude 的 MCP 服务器
|
||
let state = support::create_test_state().expect("create test state");
|
||
McpService::upsert_server(
|
||
&state,
|
||
McpServer {
|
||
id: "echo".to_string(),
|
||
name: "echo".to_string(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}),
|
||
apps: McpApps {
|
||
claude: true,
|
||
codex: false,
|
||
gemini: false,
|
||
opencode: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
)
|
||
.expect("upsert should sync to Claude live config");
|
||
|
||
// 确认已写入 ~/.claude.json
|
||
let mcp_path = get_claude_mcp_path();
|
||
let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json");
|
||
let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json");
|
||
assert!(
|
||
v.pointer("/mcpServers/echo").is_some(),
|
||
"echo should exist in Claude live config after enabling"
|
||
);
|
||
|
||
// 再次 upsert:取消勾选 Claude(apps.claude=false),应从 Claude live 配置中移除
|
||
McpService::upsert_server(
|
||
&state,
|
||
McpServer {
|
||
id: "echo".to_string(),
|
||
name: "echo".to_string(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}),
|
||
apps: McpApps {
|
||
claude: false,
|
||
codex: false,
|
||
gemini: false,
|
||
opencode: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
)
|
||
.expect("upsert disabling app should remove from Claude live config");
|
||
|
||
let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json after disable");
|
||
let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json");
|
||
assert!(
|
||
v.pointer("/mcpServers/echo").is_none(),
|
||
"echo should be removed from Claude live config after disabling"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn import_mcp_from_multiple_apps_merges_enabled_flags() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
// 1) Claude: ~/.claude.json
|
||
let mcp_path = get_claude_mcp_path();
|
||
let claude_json = json!({
|
||
"mcpServers": {
|
||
"shared": {
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}
|
||
}
|
||
});
|
||
fs::write(
|
||
&mcp_path,
|
||
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
|
||
)
|
||
.expect("seed ~/.claude.json");
|
||
|
||
// 2) Codex: ~/.codex/config.toml
|
||
let codex_dir = home.join(".codex");
|
||
fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||
fs::write(
|
||
codex_dir.join("config.toml"),
|
||
r#"[mcp_servers.shared]
|
||
type = "stdio"
|
||
command = "echo"
|
||
"#,
|
||
)
|
||
.expect("seed ~/.codex/config.toml");
|
||
|
||
let state = support::create_test_state().expect("create test state");
|
||
|
||
McpService::import_from_claude(&state).expect("import from claude");
|
||
McpService::import_from_codex(&state).expect("import from codex");
|
||
|
||
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
|
||
let entry = servers.get("shared").expect("shared server exists");
|
||
assert!(entry.apps.claude, "shared should enable Claude");
|
||
assert!(entry.apps.codex, "shared should enable Codex");
|
||
}
|
||
|
||
#[test]
|
||
fn import_mcp_from_gemini_sse_url_only_is_valid() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
// Gemini MCP 位于 ~/.gemini/settings.json
|
||
let gemini_dir = home.join(".gemini");
|
||
fs::create_dir_all(&gemini_dir).expect("create gemini dir");
|
||
let settings_path = gemini_dir.join("settings.json");
|
||
|
||
// Gemini SSE:只包含 url(Gemini 不使用 type 字段)
|
||
let gemini_settings = json!({
|
||
"mcpServers": {
|
||
"sse-server": {
|
||
"url": "https://example.com/sse"
|
||
}
|
||
}
|
||
});
|
||
fs::write(
|
||
&settings_path,
|
||
serde_json::to_string_pretty(&gemini_settings).expect("serialize gemini settings"),
|
||
)
|
||
.expect("seed ~/.gemini/settings.json");
|
||
|
||
let state = support::create_test_state().expect("create test state");
|
||
let changed = McpService::import_from_gemini(&state).expect("import from gemini");
|
||
assert!(changed > 0, "should import at least 1 server");
|
||
|
||
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
|
||
let entry = servers.get("sse-server").expect("sse-server exists");
|
||
assert!(entry.apps.gemini, "imported server should enable Gemini");
|
||
assert_eq!(
|
||
entry.server.get("type").and_then(|v| v.as_str()),
|
||
Some("sse"),
|
||
"Gemini url-only server should be normalized to type=sse in unified structure"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
|
||
use support::create_test_state;
|
||
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
// 确认 Gemini 配置目录不存在(模拟“未安装/未运行过 Gemini CLI”)
|
||
assert!(
|
||
!home.join(".gemini").exists(),
|
||
"~/.gemini should not exist in fresh test environment"
|
||
);
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
|
||
// 先插入一个未启用 Gemini 的 MCP 服务器(避免 upsert 触发同步)
|
||
McpService::upsert_server(
|
||
&state,
|
||
McpServer {
|
||
id: "gemini-server".to_string(),
|
||
name: "Gemini Server".to_string(),
|
||
server: json!({
|
||
"type": "sse",
|
||
"url": "https://example.com/sse"
|
||
}),
|
||
apps: McpApps {
|
||
claude: false,
|
||
codex: false,
|
||
gemini: false,
|
||
opencode: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
)
|
||
.expect("insert server without syncing");
|
||
|
||
// 启用 Gemini:目录缺失时应跳过写入(不创建 ~/.gemini/settings.json)
|
||
McpService::toggle_app(&state, "gemini-server", AppType::Gemini, true)
|
||
.expect("toggle gemini should succeed even when ~/.gemini is missing");
|
||
|
||
assert!(
|
||
!home.join(".gemini").exists(),
|
||
"~/.gemini should still not exist after skipped sync"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn enabling_claude_mcp_skips_when_claude_config_absent() {
|
||
use support::create_test_state;
|
||
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
// 确认 Claude 相关目录/文件都不存在(模拟“未安装/未运行过 Claude”)
|
||
assert!(
|
||
!home.join(".claude").exists(),
|
||
"~/.claude should not exist in fresh test environment"
|
||
);
|
||
assert!(
|
||
!home.join(".claude.json").exists(),
|
||
"~/.claude.json should not exist in fresh test environment"
|
||
);
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
|
||
// 先插入一个未启用 Claude 的 MCP 服务器(避免 upsert 触发同步)
|
||
McpService::upsert_server(
|
||
&state,
|
||
McpServer {
|
||
id: "claude-server".to_string(),
|
||
name: "Claude Server".to_string(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}),
|
||
apps: McpApps {
|
||
claude: false,
|
||
codex: false,
|
||
gemini: false,
|
||
opencode: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
)
|
||
.expect("insert server without syncing");
|
||
|
||
// 启用 Claude:配置缺失时应跳过写入(不创建 ~/.claude.json)
|
||
McpService::toggle_app(&state, "claude-server", AppType::Claude, true)
|
||
.expect("toggle claude should succeed even when ~/.claude is missing");
|
||
|
||
assert!(
|
||
!home.join(".claude.json").exists(),
|
||
"~/.claude.json should still not exist after skipped sync"
|
||
);
|
||
}
|