BREAKING CHANGE: The [mcp.servers] format was completely incorrect and not any official Codex format. The only correct format is [mcp_servers] at the top level of config.toml. Changes: - Remove incorrect [mcp.servers] nested table support - Always use [mcp_servers] top-level table (official Codex format) - Auto-migrate and cleanup erroneous [mcp.servers] entries on write - Preserve error-tolerant import for migrating old incorrect configs - Simplify sync logic by removing format selection branches (~60 lines) - Update all documentation and tests to reflect correct format - Add warning logs when detecting and cleaning incorrect format Backend (Rust): - mcp.rs: Simplify sync_enabled_to_codex by removing Target enum - mcp.rs: sync_single_server_to_codex now always uses [mcp_servers] - mcp.rs: remove_server_from_codex cleans both locations - mcp.rs: Update import_from_codex comments to clarify format status - tests: Rename test to sync_enabled_to_codex_migrates_erroneous_* - tests: Update assertions to verify migration behavior Frontend (TypeScript): - tomlUtils.ts: Prioritize [mcp_servers] format in parsing - tomlUtils.ts: Update error messages to guide correct format Documentation: - README.md: Correct MCP format reference to [mcp_servers] - CLAUDE.md: Add comprehensive format specification with examples All 79 tests pass. This ensures backward compatibility while enforcing the correct Codex official standard going forward. Refs: https://github.com/openai/codex/issues/3441
1111 lines
34 KiB
Rust
1111 lines
34 KiB
Rust
use serde_json::json;
|
||
use std::{fs, path::Path, sync::RwLock};
|
||
use tauri::async_runtime;
|
||
|
||
use cc_switch_lib::{
|
||
get_claude_settings_path, read_json_file, AppError, AppState, AppType, ConfigService,
|
||
MultiAppConfig, Provider, ProviderMeta,
|
||
};
|
||
|
||
#[path = "support.rs"]
|
||
mod support;
|
||
use support::{ensure_test_home, reset_test_fs, test_mutex};
|
||
|
||
#[test]
|
||
fn sync_claude_provider_writes_live_settings() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
let provider_config = json!({
|
||
"env": {
|
||
"ANTHROPIC_AUTH_TOKEN": "test-key",
|
||
"ANTHROPIC_BASE_URL": "https://api.test"
|
||
},
|
||
"ui": {
|
||
"displayName": "Test Provider"
|
||
}
|
||
});
|
||
|
||
let provider = Provider::with_id(
|
||
"prov-1".to_string(),
|
||
"Test Claude".to_string(),
|
||
provider_config.clone(),
|
||
None,
|
||
);
|
||
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Claude)
|
||
.expect("claude manager");
|
||
manager.providers.insert("prov-1".to_string(), provider);
|
||
manager.current = "prov-1".to_string();
|
||
|
||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings");
|
||
|
||
let settings_path = get_claude_settings_path();
|
||
assert!(
|
||
settings_path.exists(),
|
||
"live settings should be written to {}",
|
||
settings_path.display()
|
||
);
|
||
|
||
let live_value: serde_json::Value = read_json_file(&settings_path).expect("read live file");
|
||
assert_eq!(live_value, provider_config);
|
||
|
||
// 确认 SSOT 中的供应商也同步了最新内容
|
||
let updated = config
|
||
.get_manager(&AppType::Claude)
|
||
.and_then(|m| m.providers.get("prov-1"))
|
||
.expect("provider in config");
|
||
assert_eq!(updated.settings_config, provider_config);
|
||
|
||
// 额外确认写入位置位于测试 HOME 下
|
||
assert!(
|
||
settings_path.starts_with(home),
|
||
"settings path {settings_path:?} should reside under test HOME {home:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_codex_provider_writes_auth_and_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
|
||
// 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML
|
||
config.mcp.codex.servers.insert(
|
||
"echo-server".into(),
|
||
json!({
|
||
"id": "echo-server",
|
||
"enabled": true,
|
||
"server": {
|
||
"type": "stdio",
|
||
"command": "echo",
|
||
"args": ["hello"]
|
||
}
|
||
}),
|
||
);
|
||
|
||
let provider_config = json!({
|
||
"auth": {
|
||
"OPENAI_API_KEY": "codex-key"
|
||
},
|
||
"config": r#"base_url = "https://codex.test""#
|
||
});
|
||
|
||
let provider = Provider::with_id(
|
||
"codex-1".to_string(),
|
||
"Codex Test".to_string(),
|
||
provider_config.clone(),
|
||
None,
|
||
);
|
||
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.providers.insert("codex-1".to_string(), provider);
|
||
manager.current = "codex-1".to_string();
|
||
|
||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live");
|
||
|
||
let auth_path = cc_switch_lib::get_codex_auth_path();
|
||
let config_path = cc_switch_lib::get_codex_config_path();
|
||
|
||
assert!(
|
||
auth_path.exists(),
|
||
"auth.json should exist at {}",
|
||
auth_path.display()
|
||
);
|
||
assert!(
|
||
config_path.exists(),
|
||
"config.toml should exist at {}",
|
||
config_path.display()
|
||
);
|
||
|
||
let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth");
|
||
assert_eq!(
|
||
auth_value,
|
||
provider_config.get("auth").cloned().expect("auth object")
|
||
);
|
||
|
||
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
|
||
assert!(
|
||
toml_text.contains("command = \"echo\""),
|
||
"config.toml should contain serialized enabled MCP server"
|
||
);
|
||
|
||
// 当前供应商应同步最新 config 文本
|
||
let manager = config.get_manager(&AppType::Codex).expect("codex manager");
|
||
let synced = manager.providers.get("codex-1").expect("codex provider");
|
||
let synced_cfg = synced
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.expect("config string");
|
||
assert_eq!(synced_cfg, toml_text);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_enabled_to_codex_writes_enabled_servers() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
config.mcp.codex.servers.insert(
|
||
"stdio-enabled".into(),
|
||
json!({
|
||
"id": "stdio-enabled",
|
||
"enabled": true,
|
||
"server": {
|
||
"type": "stdio",
|
||
"command": "echo",
|
||
"args": ["ok"],
|
||
}
|
||
}),
|
||
);
|
||
|
||
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
|
||
|
||
let path = cc_switch_lib::get_codex_config_path();
|
||
assert!(path.exists(), "config.toml should be created");
|
||
let text = fs::read_to_string(&path).expect("read config.toml");
|
||
assert!(
|
||
text.contains("mcp_servers") && text.contains("stdio-enabled"),
|
||
"enabled servers should be serialized"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_enabled_to_codex_preserves_non_mcp_content_and_style() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
|
||
// 预置含有顶层注释与非 MCP 键的 config.toml
|
||
let path = cc_switch_lib::get_codex_config_path();
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).expect("create codex dir");
|
||
}
|
||
let seed = r#"# top-comment
|
||
title = "keep-me"
|
||
|
||
[profile]
|
||
mode = "dev"
|
||
"#;
|
||
fs::write(&path, seed).expect("seed config.toml");
|
||
|
||
// 启用一个 MCP 项,触发增量写入
|
||
let mut config = MultiAppConfig::default();
|
||
config.mcp.codex.servers.insert(
|
||
"echo".into(),
|
||
json!({
|
||
"id": "echo",
|
||
"enabled": true,
|
||
"server": { "type": "stdio", "command": "echo" }
|
||
}),
|
||
);
|
||
|
||
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
|
||
|
||
let text = fs::read_to_string(&path).expect("read config.toml");
|
||
// 顶层注释与非 MCP 键应保留
|
||
assert!(
|
||
text.contains("# top-comment"),
|
||
"top comment should be preserved"
|
||
);
|
||
assert!(
|
||
text.contains("title = \"keep-me\""),
|
||
"top key should be preserved"
|
||
);
|
||
assert!(
|
||
text.contains("[profile]"),
|
||
"non-MCP table should be preserved"
|
||
);
|
||
assert!(
|
||
text.contains("mcp_servers"),
|
||
"mcp_servers table should be present"
|
||
);
|
||
assert!(
|
||
!text.contains("[mcp.servers]"),
|
||
"invalid [mcp.servers] table should not appear"
|
||
);
|
||
assert!(
|
||
text.contains("echo") && text.contains("command = \"echo\""),
|
||
"echo server should be serialized"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_enabled_to_codex_migrates_erroneous_mcp_dot_servers_to_mcp_servers() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let path = cc_switch_lib::get_codex_config_path();
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).expect("create codex dir");
|
||
}
|
||
// 预置错误的 mcp.servers 风格(应迁移为顶层 mcp_servers)
|
||
let seed = r#"[mcp]
|
||
other = "keep"
|
||
[mcp.servers]
|
||
"#;
|
||
fs::write(&path, seed).expect("seed config.toml");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
config.mcp.codex.servers.insert(
|
||
"echo".into(),
|
||
json!({
|
||
"id": "echo",
|
||
"enabled": true,
|
||
"server": { "type": "stdio", "command": "echo" }
|
||
}),
|
||
);
|
||
|
||
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
|
||
let text = fs::read_to_string(&path).expect("read config.toml");
|
||
// 应迁移到顶层 mcp_servers,并移除错误的 mcp.servers 表
|
||
assert!(
|
||
text.contains("mcp_servers"),
|
||
"should migrate to mcp_servers table"
|
||
);
|
||
assert!(
|
||
!text.contains("[mcp.servers]"),
|
||
"invalid [mcp.servers] table should be removed"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_enabled_to_codex_removes_servers_when_none_enabled() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let path = cc_switch_lib::get_codex_config_path();
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).expect("create codex dir");
|
||
}
|
||
fs::write(
|
||
&path,
|
||
r#"[mcp_servers]
|
||
disabled = { type = "stdio", command = "noop" }
|
||
"#,
|
||
)
|
||
.expect("seed config file");
|
||
|
||
let config = MultiAppConfig::default(); // 无启用项
|
||
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
|
||
|
||
let text = fs::read_to_string(&path).expect("read config.toml");
|
||
assert!(
|
||
!text.contains("mcp_servers") && !text.contains("servers"),
|
||
"disabled entries should be removed from config.toml"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let path = cc_switch_lib::get_codex_config_path();
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).expect("create codex dir");
|
||
}
|
||
fs::write(&path, "invalid = [").expect("write invalid config");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
config.mcp.codex.servers.insert(
|
||
"broken".into(),
|
||
json!({
|
||
"id": "broken",
|
||
"enabled": true,
|
||
"server": {
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}
|
||
}),
|
||
);
|
||
|
||
let err = cc_switch_lib::sync_enabled_to_codex(&config).expect_err("sync should fail");
|
||
match err {
|
||
cc_switch_lib::AppError::Toml { path, .. } => {
|
||
assert!(
|
||
path.ends_with("config.toml"),
|
||
"path should reference config.toml"
|
||
);
|
||
}
|
||
cc_switch_lib::AppError::McpValidation(msg) => {
|
||
assert!(
|
||
msg.contains("config.toml"),
|
||
"error message should mention config.toml"
|
||
);
|
||
}
|
||
other => panic!("unexpected error: {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sync_codex_provider_missing_auth_returns_error() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
let provider = Provider::with_id(
|
||
"codex-missing-auth".to_string(),
|
||
"No Auth".to_string(),
|
||
json!({
|
||
"config": "model = \"test\""
|
||
}),
|
||
None,
|
||
);
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.providers.insert(provider.id.clone(), provider);
|
||
manager.current = "codex-missing-auth".to_string();
|
||
|
||
let err = ConfigService::sync_current_providers_to_live(&mut config)
|
||
.expect_err("sync should fail when auth missing");
|
||
match err {
|
||
cc_switch_lib::AppError::Config(msg) => {
|
||
assert!(msg.contains("auth"), "error message should mention auth");
|
||
}
|
||
other => panic!("unexpected error variant: {other:?}"),
|
||
}
|
||
|
||
// 确认未产生任何 live 配置文件
|
||
assert!(
|
||
!cc_switch_lib::get_codex_auth_path().exists(),
|
||
"auth.json should not be created on failure"
|
||
);
|
||
assert!(
|
||
!cc_switch_lib::get_codex_config_path().exists(),
|
||
"config.toml should not be created on failure"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn write_codex_live_atomic_persists_auth_and_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
|
||
let auth = json!({ "OPENAI_API_KEY": "dev-key" });
|
||
let config_text = r#"
|
||
[mcp_servers.echo]
|
||
type = "stdio"
|
||
command = "echo"
|
||
args = ["ok"]
|
||
"#;
|
||
|
||
cc_switch_lib::write_codex_live_atomic(&auth, Some(config_text))
|
||
.expect("atomic write should succeed");
|
||
|
||
let auth_path = cc_switch_lib::get_codex_auth_path();
|
||
let config_path = cc_switch_lib::get_codex_config_path();
|
||
assert!(auth_path.exists(), "auth.json should be created");
|
||
assert!(config_path.exists(), "config.toml should be created");
|
||
|
||
let stored_auth: serde_json::Value =
|
||
cc_switch_lib::read_json_file(&auth_path).expect("read auth");
|
||
assert_eq!(stored_auth, auth, "auth.json should match input");
|
||
|
||
let stored_config = std::fs::read_to_string(&config_path).expect("read config");
|
||
assert!(
|
||
stored_config.contains("mcp_servers.echo"),
|
||
"config.toml should contain serialized table"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn write_codex_live_atomic_rolls_back_auth_when_config_write_fails() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
|
||
let auth_path = cc_switch_lib::get_codex_auth_path();
|
||
if let Some(parent) = auth_path.parent() {
|
||
std::fs::create_dir_all(parent).expect("create codex dir");
|
||
}
|
||
std::fs::write(&auth_path, r#"{"OPENAI_API_KEY":"legacy"}"#).expect("seed auth");
|
||
|
||
let config_path = cc_switch_lib::get_codex_config_path();
|
||
std::fs::create_dir_all(&config_path).expect("create blocking directory");
|
||
|
||
let auth = json!({ "OPENAI_API_KEY": "new-key" });
|
||
let config_text = r#"[mcp_servers.sample]
|
||
type = "stdio"
|
||
command = "noop"
|
||
"#;
|
||
|
||
let err = cc_switch_lib::write_codex_live_atomic(&auth, Some(config_text))
|
||
.expect_err("config write should fail when target is directory");
|
||
match err {
|
||
cc_switch_lib::AppError::Io { path, .. } => {
|
||
assert!(
|
||
path.ends_with("config.toml"),
|
||
"io error path should point to config.toml"
|
||
);
|
||
}
|
||
cc_switch_lib::AppError::IoContext { context, .. } => {
|
||
assert!(
|
||
context.contains("config.toml"),
|
||
"error context should mention config path"
|
||
);
|
||
}
|
||
other => panic!("unexpected error variant: {other:?}"),
|
||
}
|
||
|
||
let stored = std::fs::read_to_string(&auth_path).expect("read existing auth");
|
||
assert!(
|
||
stored.contains("legacy"),
|
||
"auth.json should roll back to legacy content"
|
||
);
|
||
assert!(
|
||
std::fs::metadata(&config_path)
|
||
.expect("config path metadata")
|
||
.is_dir(),
|
||
"config path should remain a directory after failure"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn import_from_codex_adds_servers_from_mcp_servers_table() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let path = cc_switch_lib::get_codex_config_path();
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).expect("create codex dir");
|
||
}
|
||
fs::write(
|
||
&path,
|
||
r#"[mcp_servers.echo_server]
|
||
type = "stdio"
|
||
command = "echo"
|
||
args = ["hello"]
|
||
|
||
[mcp_servers.http_server]
|
||
type = "http"
|
||
url = "https://example.com"
|
||
"#,
|
||
)
|
||
.expect("write codex config");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
|
||
assert!(changed >= 2, "should import both servers");
|
||
|
||
// v3.7.0: 检查统一结构
|
||
let servers = config
|
||
.mcp
|
||
.servers
|
||
.as_ref()
|
||
.expect("unified servers should exist");
|
||
|
||
let echo = servers.get("echo_server").expect("echo server");
|
||
assert_eq!(
|
||
echo.apps.codex, true,
|
||
"Codex app should be enabled for echo_server"
|
||
);
|
||
let server_spec = echo.server.as_object().expect("server spec");
|
||
assert_eq!(
|
||
server_spec
|
||
.get("command")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or(""),
|
||
"echo"
|
||
);
|
||
|
||
let http = servers.get("http_server").expect("http server");
|
||
assert_eq!(
|
||
http.apps.codex, true,
|
||
"Codex app should be enabled for http_server"
|
||
);
|
||
let http_spec = http.server.as_object().expect("http spec");
|
||
assert_eq!(
|
||
http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""),
|
||
"https://example.com"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn import_from_codex_merges_into_existing_entries() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let path = cc_switch_lib::get_codex_config_path();
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).expect("create codex dir");
|
||
}
|
||
fs::write(
|
||
&path,
|
||
r#"[mcp.servers.existing]
|
||
type = "stdio"
|
||
command = "echo"
|
||
"#,
|
||
)
|
||
.expect("write codex config");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
// v3.7.0: 在统一结构中创建已存在的服务器
|
||
config.mcp.servers = Some(std::collections::HashMap::new());
|
||
config.mcp.servers.as_mut().unwrap().insert(
|
||
"existing".to_string(),
|
||
cc_switch_lib::McpServer {
|
||
id: "existing".to_string(),
|
||
name: "existing".to_string(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "prev"
|
||
}),
|
||
apps: cc_switch_lib::McpApps {
|
||
claude: false,
|
||
codex: false, // 初始未启用
|
||
gemini: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
);
|
||
|
||
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
|
||
assert!(changed >= 1, "should mark change for enabled flag");
|
||
|
||
// v3.7.0: 检查统一结构
|
||
let entry = config
|
||
.mcp
|
||
.servers
|
||
.as_ref()
|
||
.unwrap()
|
||
.get("existing")
|
||
.expect("existing entry");
|
||
|
||
// 验证 Codex 应用已启用
|
||
assert_eq!(
|
||
entry.apps.codex, true,
|
||
"Codex app should be enabled after import"
|
||
);
|
||
|
||
// 验证现有配置被保留(server 不应被覆盖)
|
||
let spec = entry.server.as_object().expect("server spec");
|
||
assert_eq!(
|
||
spec.get("command").and_then(|v| v.as_str()),
|
||
Some("prev"),
|
||
"existing server config should be preserved, not overwritten by import"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_claude_enabled_mcp_projects_to_user_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let mut config = MultiAppConfig::default();
|
||
|
||
config.mcp.claude.servers.insert(
|
||
"stdio-enabled".into(),
|
||
json!({
|
||
"id": "stdio-enabled",
|
||
"enabled": true,
|
||
"server": {
|
||
"type": "stdio",
|
||
"command": "echo",
|
||
"args": ["hi"],
|
||
}
|
||
}),
|
||
);
|
||
config.mcp.claude.servers.insert(
|
||
"http-disabled".into(),
|
||
json!({
|
||
"id": "http-disabled",
|
||
"enabled": false,
|
||
"server": {
|
||
"type": "http",
|
||
"url": "https://example.com",
|
||
}
|
||
}),
|
||
);
|
||
|
||
cc_switch_lib::sync_enabled_to_claude(&config).expect("sync Claude MCP");
|
||
|
||
let claude_path = cc_switch_lib::get_claude_mcp_path();
|
||
assert!(claude_path.exists(), "claude config should exist");
|
||
let text = fs::read_to_string(&claude_path).expect("read .claude.json");
|
||
let value: serde_json::Value = serde_json::from_str(&text).expect("parse claude json");
|
||
let servers = value
|
||
.get("mcpServers")
|
||
.and_then(|v| v.as_object())
|
||
.expect("mcpServers map");
|
||
assert_eq!(servers.len(), 1, "only enabled entries should be written");
|
||
let enabled = servers.get("stdio-enabled").expect("enabled entry");
|
||
assert_eq!(
|
||
enabled
|
||
.get("command")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or_default(),
|
||
"echo"
|
||
);
|
||
assert!(servers.get("http-disabled").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn import_from_claude_merges_into_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
let claude_path = home.join(".claude.json");
|
||
|
||
fs::write(
|
||
&claude_path,
|
||
serde_json::to_string_pretty(&json!({
|
||
"mcpServers": {
|
||
"stdio-enabled": {
|
||
"type": "stdio",
|
||
"command": "echo",
|
||
"args": ["hello"]
|
||
}
|
||
}
|
||
}))
|
||
.unwrap(),
|
||
)
|
||
.expect("write claude json");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
// v3.7.0: 在统一结构中创建已存在的服务器
|
||
config.mcp.servers = Some(std::collections::HashMap::new());
|
||
config.mcp.servers.as_mut().unwrap().insert(
|
||
"stdio-enabled".to_string(),
|
||
cc_switch_lib::McpServer {
|
||
id: "stdio-enabled".to_string(),
|
||
name: "stdio-enabled".to_string(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "prev"
|
||
}),
|
||
apps: cc_switch_lib::McpApps {
|
||
claude: false, // 初始未启用
|
||
codex: false,
|
||
gemini: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
);
|
||
|
||
let changed = cc_switch_lib::import_from_claude(&mut config).expect("import from claude");
|
||
assert!(changed >= 1, "should mark at least one change");
|
||
|
||
// v3.7.0: 检查统一结构
|
||
let entry = config
|
||
.mcp
|
||
.servers
|
||
.as_ref()
|
||
.unwrap()
|
||
.get("stdio-enabled")
|
||
.expect("entry exists");
|
||
|
||
// 验证 Claude 应用已启用
|
||
assert_eq!(
|
||
entry.apps.claude, true,
|
||
"Claude app should be enabled after import"
|
||
);
|
||
|
||
// 验证现有配置被保留(server 不应被覆盖)
|
||
let server = entry.server.as_object().expect("server obj");
|
||
assert_eq!(
|
||
server.get("command").and_then(|v| v.as_str()).unwrap_or(""),
|
||
"prev",
|
||
"existing server config should be preserved"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn create_backup_skips_missing_file() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
let config_path = home.join(".cc-switch").join("config.json");
|
||
|
||
// 未创建文件时应返回空字符串,不报错
|
||
let result = ConfigService::create_backup(&config_path).expect("create backup");
|
||
assert!(
|
||
result.is_empty(),
|
||
"expected empty backup id when config file missing"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn create_backup_generates_snapshot_file() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
let config_dir = home.join(".cc-switch");
|
||
let config_path = config_dir.join("config.json");
|
||
fs::create_dir_all(&config_dir).expect("prepare config dir");
|
||
fs::write(&config_path, r#"{"version":2}"#).expect("write config file");
|
||
|
||
let backup_id = ConfigService::create_backup(&config_path).expect("backup success");
|
||
assert!(
|
||
!backup_id.is_empty(),
|
||
"backup id should contain timestamp information"
|
||
);
|
||
|
||
let backup_path = config_dir.join("backups").join(format!("{backup_id}.json"));
|
||
assert!(
|
||
backup_path.exists(),
|
||
"expected backup file at {}",
|
||
backup_path.display()
|
||
);
|
||
|
||
let backup_content = fs::read_to_string(&backup_path).expect("read backup");
|
||
assert!(
|
||
backup_content.contains(r#""version":2"#),
|
||
"backup content should match original config"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn create_backup_retains_only_latest_entries() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
let config_dir = home.join(".cc-switch");
|
||
let config_path = config_dir.join("config.json");
|
||
fs::create_dir_all(&config_dir).expect("prepare config dir");
|
||
fs::write(&config_path, r#"{"version":3}"#).expect("write config file");
|
||
|
||
let backups_dir = config_dir.join("backups");
|
||
fs::create_dir_all(&backups_dir).expect("create backups dir");
|
||
for idx in 0..12 {
|
||
let manual = backups_dir.join(format!("manual_{idx:02}.json"));
|
||
fs::write(&manual, format!("{{\"idx\":{idx}}}")).expect("seed manual backup");
|
||
}
|
||
|
||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||
|
||
let latest_backup_id =
|
||
ConfigService::create_backup(&config_path).expect("create backup with cleanup");
|
||
assert!(
|
||
!latest_backup_id.is_empty(),
|
||
"backup id should not be empty when config exists"
|
||
);
|
||
|
||
let entries: Vec<_> = fs::read_dir(&backups_dir)
|
||
.expect("read backups dir")
|
||
.filter_map(|entry| entry.ok())
|
||
.collect();
|
||
assert!(
|
||
entries.len() <= 10,
|
||
"expected backups to be trimmed to at most 10 files, got {}",
|
||
entries.len()
|
||
);
|
||
|
||
let latest_path = backups_dir.join(format!("{latest_backup_id}.json"));
|
||
assert!(
|
||
latest_path.exists(),
|
||
"latest backup {} should be preserved",
|
||
latest_path.display()
|
||
);
|
||
|
||
// 进一步确认保留的条目包含一些历史文件,说明清理逻辑仅裁剪多余部分
|
||
let manual_kept = entries
|
||
.iter()
|
||
.filter_map(|entry| entry.file_name().into_string().ok())
|
||
.any(|name| name.starts_with("manual_"));
|
||
assert!(
|
||
manual_kept,
|
||
"cleanup should keep part of the older backups to maintain history"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn import_config_from_path_overwrites_state_and_creates_backup() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let config_dir = home.join(".cc-switch");
|
||
fs::create_dir_all(&config_dir).expect("create config dir");
|
||
let config_path = config_dir.join("config.json");
|
||
fs::write(&config_path, r#"{"version":1}"#).expect("seed original config");
|
||
|
||
let import_payload = serde_json::json!({
|
||
"version": 2,
|
||
"claude": {
|
||
"providers": {
|
||
"p-new": {
|
||
"id": "p-new",
|
||
"name": "Test Claude",
|
||
"settingsConfig": {
|
||
"env": { "ANTHROPIC_API_KEY": "new-key" }
|
||
}
|
||
}
|
||
},
|
||
"current": "p-new"
|
||
},
|
||
"codex": {
|
||
"providers": {},
|
||
"current": ""
|
||
},
|
||
"mcp": {
|
||
"claude": { "servers": {} },
|
||
"codex": { "servers": {} }
|
||
}
|
||
});
|
||
|
||
let import_path = config_dir.join("import.json");
|
||
fs::write(
|
||
&import_path,
|
||
serde_json::to_string_pretty(&import_payload).expect("serialize import payload"),
|
||
)
|
||
.expect("write import file");
|
||
|
||
let app_state = AppState {
|
||
config: RwLock::new(MultiAppConfig::default()),
|
||
};
|
||
|
||
let backup_id = ConfigService::import_config_from_path(&import_path, &app_state)
|
||
.expect("import should succeed");
|
||
assert!(
|
||
!backup_id.is_empty(),
|
||
"expected backup id when original config exists"
|
||
);
|
||
|
||
let backup_path = config_dir.join("backups").join(format!("{backup_id}.json"));
|
||
assert!(
|
||
backup_path.exists(),
|
||
"backup file should exist at {}",
|
||
backup_path.display()
|
||
);
|
||
|
||
let updated_content = fs::read_to_string(&config_path).expect("read updated config");
|
||
let parsed: serde_json::Value =
|
||
serde_json::from_str(&updated_content).expect("parse updated config");
|
||
assert_eq!(
|
||
parsed
|
||
.get("claude")
|
||
.and_then(|c| c.get("current"))
|
||
.and_then(|c| c.as_str()),
|
||
Some("p-new"),
|
||
"saved config should record new current provider"
|
||
);
|
||
|
||
let guard = app_state.config.read().expect("lock state after import");
|
||
let claude_manager = guard
|
||
.get_manager(&AppType::Claude)
|
||
.expect("claude manager in state");
|
||
assert_eq!(
|
||
claude_manager.current, "p-new",
|
||
"state should reflect new current provider"
|
||
);
|
||
assert!(
|
||
claude_manager.providers.contains_key("p-new"),
|
||
"new provider should exist in state"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn import_config_from_path_invalid_json_returns_error() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let config_dir = home.join(".cc-switch");
|
||
fs::create_dir_all(&config_dir).expect("create config dir");
|
||
|
||
let invalid_path = config_dir.join("broken.json");
|
||
fs::write(&invalid_path, "{ not-json ").expect("write invalid json");
|
||
|
||
let app_state = AppState {
|
||
config: RwLock::new(MultiAppConfig::default()),
|
||
};
|
||
|
||
let err = ConfigService::import_config_from_path(&invalid_path, &app_state)
|
||
.expect_err("import should fail");
|
||
match err {
|
||
AppError::Json { .. } => {}
|
||
other => panic!("expected json error, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn import_config_from_path_missing_file_produces_io_error() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let missing_path = Path::new("/nonexistent/import.json");
|
||
let app_state = AppState {
|
||
config: RwLock::new(MultiAppConfig::default()),
|
||
};
|
||
|
||
let err = ConfigService::import_config_from_path(missing_path, &app_state)
|
||
.expect_err("import should fail for missing file");
|
||
match err {
|
||
AppError::Io { .. } => {}
|
||
other => panic!("expected io error, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sync_gemini_packycode_sets_security_selected_type() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Gemini)
|
||
.expect("gemini manager");
|
||
manager.current = "packy-1".to_string();
|
||
manager.providers.insert(
|
||
"packy-1".to_string(),
|
||
Provider::with_id(
|
||
"packy-1".to_string(),
|
||
"PackyCode".to_string(),
|
||
json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "pk-key",
|
||
"GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com"
|
||
}
|
||
}),
|
||
Some("https://www.packyapi.com".to_string()),
|
||
),
|
||
);
|
||
}
|
||
|
||
ConfigService::sync_current_providers_to_live(&mut config)
|
||
.expect("syncing gemini live should succeed");
|
||
|
||
let settings_path = home.join(".cc-switch").join("settings.json");
|
||
assert!(
|
||
settings_path.exists(),
|
||
"settings.json should exist at {}",
|
||
settings_path.display()
|
||
);
|
||
|
||
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
|
||
assert_eq!(
|
||
value
|
||
.pointer("/security/auth/selectedType")
|
||
.and_then(|v| v.as_str()),
|
||
Some("gemini-api-key"),
|
||
"syncing PackyCode Gemini should enforce security.auth.selectedType"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_gemini_google_official_sets_oauth_security() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Gemini)
|
||
.expect("gemini manager");
|
||
manager.current = "google-official".to_string();
|
||
let mut provider = Provider::with_id(
|
||
"google-official".to_string(),
|
||
"Google".to_string(),
|
||
json!({
|
||
"env": {}
|
||
}),
|
||
Some("https://ai.google.dev".to_string()),
|
||
);
|
||
provider.meta = Some(ProviderMeta {
|
||
partner_promotion_key: Some("google-official".to_string()),
|
||
..ProviderMeta::default()
|
||
});
|
||
manager
|
||
.providers
|
||
.insert("google-official".to_string(), provider);
|
||
}
|
||
|
||
ConfigService::sync_current_providers_to_live(&mut config)
|
||
.expect("syncing google official gemini should succeed");
|
||
|
||
let cc_settings = home.join(".cc-switch").join("settings.json");
|
||
assert!(
|
||
cc_settings.exists(),
|
||
"app settings should exist at {}",
|
||
cc_settings.display()
|
||
);
|
||
let cc_raw = std::fs::read_to_string(&cc_settings).expect("read .cc-switch settings");
|
||
let cc_value: serde_json::Value = serde_json::from_str(&cc_raw).expect("parse app settings");
|
||
assert_eq!(
|
||
cc_value
|
||
.pointer("/security/auth/selectedType")
|
||
.and_then(|v| v.as_str()),
|
||
Some("oauth-personal"),
|
||
"syncing Google official should set oauth-personal in app settings"
|
||
);
|
||
|
||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||
assert!(
|
||
gemini_settings.exists(),
|
||
"Gemini settings should exist at {}",
|
||
gemini_settings.display()
|
||
);
|
||
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
||
let gemini_value: serde_json::Value =
|
||
serde_json::from_str(&gemini_raw).expect("parse gemini settings json");
|
||
assert_eq!(
|
||
gemini_value
|
||
.pointer("/security/auth/selectedType")
|
||
.and_then(|v| v.as_str()),
|
||
Some("oauth-personal"),
|
||
"Gemini settings should also record oauth-personal"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn export_config_to_file_writes_target_path() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let config_dir = home.join(".cc-switch");
|
||
fs::create_dir_all(&config_dir).expect("create config dir");
|
||
let config_path = config_dir.join("config.json");
|
||
fs::write(&config_path, r#"{"version":42,"flag":true}"#).expect("write config");
|
||
|
||
let export_path = home.join("exported-config.json");
|
||
if export_path.exists() {
|
||
fs::remove_file(&export_path).expect("cleanup export target");
|
||
}
|
||
|
||
let result = async_runtime::block_on(cc_switch_lib::export_config_to_file(
|
||
export_path.to_string_lossy().to_string(),
|
||
))
|
||
.expect("export should succeed");
|
||
assert_eq!(result.get("success").and_then(|v| v.as_bool()), Some(true));
|
||
|
||
let exported = fs::read_to_string(&export_path).expect("read exported file");
|
||
assert!(
|
||
exported.contains(r#""version":42"#) && exported.contains(r#""flag":true"#),
|
||
"exported file should mirror source config content"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn export_config_to_file_returns_error_when_source_missing() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let export_path = home.join("export-missing.json");
|
||
if export_path.exists() {
|
||
fs::remove_file(&export_path).expect("cleanup export target");
|
||
}
|
||
|
||
let err = async_runtime::block_on(cc_switch_lib::export_config_to_file(
|
||
export_path.to_string_lossy().to_string(),
|
||
))
|
||
.expect_err("export should fail when config.json missing");
|
||
assert!(
|
||
err.contains("IO 错误"),
|
||
"expected IO error message, got {err}"
|
||
);
|
||
}
|