Extract all MCP business logic from command layer into `services/mcp.rs`, implementing snapshot isolation pattern to optimize lock granularity after RwLock migration in Phase 5. ## Key Changes ### Service Layer (`services/mcp.rs`) - Add `McpService` with 7 methods: `get_servers`, `upsert_server`, `delete_server`, `set_enabled`, `sync_enabled`, `import_from_claude`, `import_from_codex` - Implement snapshot isolation: acquire write lock only for in-memory modifications, clone config snapshot, release lock, then perform file I/O with snapshot - Use conditional cloning: only clone config when sync is actually needed (e.g., when `enabled` flag is true or `sync_other_side` is requested) ### Command Layer (`commands/mcp.rs`) - Reduce to thin wrappers: parse parameters and delegate to `McpService` - Remove all `*_internal` and `*_test_hook` functions (-94 lines) - Each command now 5-10 lines (parameter parsing + service call + error mapping) ### Core Logic Refactoring (`mcp.rs`) - Rename `set_enabled_and_sync_for` → `set_enabled_flag_for` - Remove file sync logic from low-level function, move sync responsibility to service layer for better separation of concerns ### Test Adaptation (`tests/mcp_commands.rs`) - Replace test hooks with direct `McpService` calls - All 5 MCP integration tests pass ### Additional Fixes - Add `Default` impl for `AppState` (clippy suggestion) - Remove unnecessary auto-deref in `commands/provider.rs` and `lib.rs` - Update Phase 4/5 progress in `BACKEND_REFACTOR_PLAN.md` ## Performance Impact **Before**: Write lock held during file I/O (~10ms), blocking all readers **After**: Write lock held only for memory ops (~100μs), file I/O lock-free Estimated throughput improvement: ~2x in high-concurrency read scenarios ## Testing - ✅ All tests pass: 5 MCP commands + 7 provider service tests - ✅ Zero clippy warnings with `-D warnings` - ✅ No behavioral changes, maintains original save semantics Part of Phase 4 (Service Layer Abstraction) of backend refactoring roadmap.
231 lines
6.7 KiB
Rust
231 lines
6.7 KiB
Rust
use std::{fs, sync::RwLock};
|
|
|
|
use serde_json::json;
|
|
|
|
use cc_switch_lib::{
|
|
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
|
|
AppState, AppType, McpService, MultiAppConfig,
|
|
};
|
|
|
|
#[path = "support.rs"]
|
|
mod support;
|
|
use support::{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 = AppState {
|
|
config: RwLock::new(config),
|
|
};
|
|
|
|
import_default_config_test_hook(&state, AppType::Claude)
|
|
.expect("import default config succeeds");
|
|
|
|
// 验证内存状态
|
|
let guard = state.config.read().expect("lock config");
|
|
let manager = guard
|
|
.get_manager(&AppType::Claude)
|
|
.expect("claude manager present");
|
|
assert_eq!(manager.current, "default");
|
|
let default_provider = manager.providers.get("default").expect("default provider");
|
|
assert_eq!(
|
|
default_provider.settings_config, settings,
|
|
"default provider should capture live settings"
|
|
);
|
|
drop(guard);
|
|
|
|
// 验证配置已持久化
|
|
let config_path = home.join(".cc-switch").join("config.json");
|
|
assert!(
|
|
config_path.exists(),
|
|
"importing default config should persist config.json"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn import_default_config_without_live_file_returns_error() {
|
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
reset_test_fs();
|
|
let home = ensure_test_home();
|
|
|
|
let state = AppState {
|
|
config: RwLock::new(MultiAppConfig::default()),
|
|
};
|
|
|
|
let err = import_default_config_test_hook(&state, AppType::Claude)
|
|
.expect_err("missing live file should error");
|
|
match err {
|
|
AppError::Message(msg) => assert!(
|
|
msg.contains("Claude Code 配置文件不存在"),
|
|
"unexpected error message: {msg}"
|
|
),
|
|
other => panic!("unexpected error variant: {other:?}"),
|
|
}
|
|
|
|
let config_path = home.join(".cc-switch").join("config.json");
|
|
assert!(
|
|
!config_path.exists(),
|
|
"failed import should not create config.json"
|
|
);
|
|
}
|
|
|
|
#[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 state = AppState {
|
|
config: RwLock::new(MultiAppConfig::default()),
|
|
};
|
|
|
|
let changed = McpService::import_from_claude(&state).expect("import mcp from claude succeeds");
|
|
assert!(
|
|
changed > 0,
|
|
"import should report inserted or normalized entries"
|
|
);
|
|
|
|
let guard = state.config.read().expect("lock config");
|
|
let claude_servers = &guard.mcp.claude.servers;
|
|
let entry = claude_servers
|
|
.get("echo")
|
|
.expect("server imported into config.json");
|
|
assert!(
|
|
entry
|
|
.get("enabled")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false),
|
|
"imported server should be marked enabled"
|
|
);
|
|
drop(guard);
|
|
|
|
let config_path = home.join(".cc-switch").join("config.json");
|
|
assert!(
|
|
config_path.exists(),
|
|
"state.save should persist config.json when changes detected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn import_mcp_from_claude_invalid_json_preserves_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 = AppState {
|
|
config: RwLock::new(MultiAppConfig::default()),
|
|
};
|
|
|
|
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:?}"),
|
|
}
|
|
|
|
let config_path = home.join(".cc-switch").join("config.json");
|
|
assert!(
|
|
!config_path.exists(),
|
|
"failed import should not persist config.json"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_mcp_enabled_for_codex_writes_live_config() {
|
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
reset_test_fs();
|
|
ensure_test_home();
|
|
|
|
let mut config = MultiAppConfig::default();
|
|
config.ensure_app(&AppType::Codex);
|
|
config.mcp.codex.servers.insert(
|
|
"codex-server".into(),
|
|
json!({
|
|
"id": "codex-server",
|
|
"name": "Codex Server",
|
|
"server": {
|
|
"type": "stdio",
|
|
"command": "echo"
|
|
},
|
|
"enabled": false
|
|
}),
|
|
);
|
|
|
|
let state = AppState {
|
|
config: RwLock::new(config),
|
|
};
|
|
|
|
McpService::set_enabled(&state, AppType::Codex, "codex-server", true)
|
|
.expect("set enabled should succeed");
|
|
|
|
let guard = state.config.read().expect("lock config");
|
|
let entry = guard
|
|
.mcp
|
|
.codex
|
|
.servers
|
|
.get("codex-server")
|
|
.expect("codex server exists");
|
|
assert!(
|
|
entry
|
|
.get("enabled")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false),
|
|
"server should be marked enabled after command"
|
|
);
|
|
drop(guard);
|
|
|
|
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"
|
|
);
|
|
}
|