use std::collections::HashMap; use std::fs; use serde_json::json; use cc_switch_lib::{ get_claude_mcp_path, get_claude_mcp_status, get_claude_settings_path, get_grok_config_path, import_default_config_test_hook, read_claude_mcp_config, update_settings, AppError, AppSettings, AppType, McpApps, McpServer, McpService, MultiAppConfig, ProviderService, }; #[path = "support.rs"] mod support; use support::{ create_test_state, 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_grokbuild_seeds_official_alongside_default() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let _home = ensure_test_home(); let config_path = get_grok_config_path(); if let Some(parent) = config_path.parent() { fs::create_dir_all(parent).expect("create grok config dir"); } fs::write( &config_path, r#"[models] default = "grok-4.5" [model."grok-4.5"] model = "grok-4.5" base_url = "https://example.com/v1" name = "Example" api_key = "secret" api_backend = "responses" context_window = 500000 "#, ) .expect("seed grok config.toml"); let mut config = MultiAppConfig::default(); config.ensure_app(&AppType::GrokBuild); let state = create_test_state_with_config(&config).expect("create test state"); import_default_config_test_hook(&state, AppType::GrokBuild) .expect("import default config succeeds"); let providers = state .db .get_all_providers(AppType::GrokBuild.as_str()) .expect("get all providers"); assert!( providers.get("default").is_some(), "live imported as default" ); // 初次导入已有配置时应同时补出官方入口(其它应用靠首启动主播种, // grokbuild 种子晚于该 flag,挂在导入动作上) let official = providers .get("grokbuild-official") .expect("official seed ensured alongside import"); assert_eq!(official.category.as_deref(), Some("official")); // 激活的仍是导入的原配置,官方入口只是备选 let current_id = state .db .get_current_provider(AppType::GrokBuild.as_str()) .expect("get current provider"); assert_eq!(current_id.as_deref(), Some("default")); } #[test] fn import_default_config_grokbuild_official_live_imports_official_as_current() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let _home = ensure_test_home(); // 官方登录态的 live:无自定义模型表(允许 MCP 等其它内容)。 // 导入的正确结果 = Grok Official 成为当前供应商,而非报错。 let config_path = get_grok_config_path(); if let Some(parent) = config_path.parent() { fs::create_dir_all(parent).expect("create grok config dir"); } fs::write(&config_path, "[mcp_servers.echo]\ncommand = \"echo\"\n") .expect("seed official-mode grok config.toml"); let mut config = MultiAppConfig::default(); config.ensure_app(&AppType::GrokBuild); let state = create_test_state_with_config(&config).expect("create test state"); let imported = import_default_config_test_hook(&state, AppType::GrokBuild) .expect("official-mode live imports as the official provider"); assert!(imported, "official-mode import should report success"); let providers = state .db .get_all_providers(AppType::GrokBuild.as_str()) .expect("get all providers"); let official = providers .get("grokbuild-official") .expect("official entry ensured by import"); assert_eq!(official.category.as_deref(), Some("official")); assert!( providers.get("default").is_none(), "official-mode live must not be imported as a custom default" ); let current_id = state .db .get_current_provider(AppType::GrokBuild.as_str()) .expect("get current provider"); assert_eq!( current_id.as_deref(), Some("grokbuild-official"), "official entry should become current to mirror the live state" ); } #[test] fn startup_import_grokbuild_official_live_does_not_resurrect_official() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let _home = ensure_test_home(); // 启动自动导入走 service 层(lib.rs 启动循环直接调用它):官方态 live // 必须报错且不产出任何条目——全项目惯例是启动自动导入只产出 default、 // 从不产出官方条目,否则删掉的官方条目每次重启都会复活。 // 官方态的成功导入只挂在手动导入的命令层。 let config_path = get_grok_config_path(); if let Some(parent) = config_path.parent() { fs::create_dir_all(parent).expect("create grok config dir"); } fs::write(&config_path, "").expect("seed empty official-mode grok config.toml"); let mut config = MultiAppConfig::default(); config.ensure_app(&AppType::GrokBuild); let state = create_test_state_with_config(&config).expect("create test state"); ProviderService::import_default_config(&state, AppType::GrokBuild) .expect_err("startup auto-import must not import official-mode live"); let providers = state .db .get_all_providers(AppType::GrokBuild.as_str()) .expect("get all providers"); assert!( providers.is_empty(), "startup auto-import must not create any provider from official-mode live" ); } #[test] fn import_default_config_grokbuild_broken_custom_live_still_errors() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let _home = ensure_test_home(); // 有自定义痕迹但残缺([models] 存在、缺 [model.*]):必须报真实错误, // 不能被误判成官方态静默吞掉;官方入口仍由命令层前置 ensure 补出。 let config_path = get_grok_config_path(); if let Some(parent) = config_path.parent() { fs::create_dir_all(parent).expect("create grok config dir"); } fs::write(&config_path, "[models]\ndefault = \"grok-4.5\"\n") .expect("seed broken custom grok config.toml"); let mut config = MultiAppConfig::default(); config.ensure_app(&AppType::GrokBuild); let state = create_test_state_with_config(&config).expect("create test state"); import_default_config_test_hook(&state, AppType::GrokBuild) .expect_err("broken custom config should surface a validation error"); let providers = state .db .get_all_providers(AppType::GrokBuild.as_str()) .expect("get all providers"); assert!( providers.get("grokbuild-official").is_some(), "official entry still appears via the pre-import ensure" ); assert!(providers.get("default").is_none(), "nothing was imported"); let current_id = state .db .get_current_provider(AppType::GrokBuild.as_str()) .expect("get current provider"); assert_ne!( current_id.as_deref(), Some("grokbuild-official"), "failed import must not silently activate the official entry" ); } #[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_codex_does_not_rewrite_codex_config() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let home = ensure_test_home(); let codex_dir = home.join(".codex"); fs::create_dir_all(&codex_dir).expect("create codex dir"); let config_path = codex_dir.join("config.toml"); let original = r#"# keep user formatting intact model = "gpt-5" [mcp.servers.legacy] type = "stdio" command = "echo" [mcp_servers.echo] type = "stdio" command = "echo" "#; fs::write(&config_path, original).expect("seed codex config"); let state = create_test_state().expect("create test state"); let changed = McpService::import_from_codex(&state).expect("import from codex"); assert!(changed > 0, "should import servers from Codex config"); let after = fs::read_to_string(&config_path).expect("read codex config"); assert_eq!( after, original, "importing from Codex should not rewrite ~/.codex/config.toml" ); } #[test] fn import_mcp_from_claude_does_not_sync_existing_codex_enabled_server() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let home = ensure_test_home(); let codex_dir = home.join(".codex"); fs::create_dir_all(&codex_dir).expect("create codex dir"); let codex_config_path = codex_dir.join("config.toml"); let codex_original = r#"[mcp.servers.keep_me] type = "stdio" command = "echo" "#; fs::write(&codex_config_path, codex_original).expect("seed codex config"); let claude_json = json!({ "mcpServers": { "shared": { "type": "stdio", "command": "echo" } } }); fs::write( get_claude_mcp_path(), serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"), ) .expect("seed claude mcp"); let state = create_test_state().expect("create test state"); state .db .save_mcp_server(&McpServer { id: "shared".to_string(), name: "shared".to_string(), server: json!({ "type": "stdio", "command": "echo" }), apps: McpApps { claude: false, codex: true, gemini: false, grokbuild: false, opencode: false, hermes: false, }, description: None, homepage: None, docs: None, tags: Vec::new(), }) .expect("seed existing mcp server"); let changed = McpService::import_from_claude(&state).expect("import from claude"); assert_eq!(changed, 0, "existing server should not count as new"); let after = fs::read_to_string(&codex_config_path).expect("read codex config"); assert_eq!( after, codex_original, "importing from Claude should not sync an existing Codex-enabled server" ); let servers = state.db.get_all_mcp_servers().expect("get all mcp servers"); let shared = servers.get("shared").expect("shared server exists"); assert!( shared.apps.claude, "import should enable Claude in database" ); assert!(shared.apps.codex, "existing Codex flag should be preserved"); } #[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" ); } /// "从应用导入"是 best-effort:单个应用的坏配置文件不阻断其余应用的 /// 导入,但失败必须聚合上报——历史实现逐应用 `unwrap_or(0)` 吞错, /// 坏 config.toml 只会表现为"导入成功 0 个",用户无从得知出了什么问题。 #[test] fn import_from_all_apps_reports_broken_app_but_imports_the_rest() { use support::create_test_state; let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let home = ensure_test_home(); // 好的 ~/.claude.json:应正常导入 let claude_json = json!({ "mcpServers": { "alpha": { "type": "stdio", "command": "echo" } } }); fs::write( get_claude_mcp_path(), serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"), ) .expect("seed ~/.claude.json"); // 坏的 ~/.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"), "not = = valid toml") .expect("seed broken codex config"); let state = create_test_state().expect("create test state"); let err = McpService::import_from_all_apps(&state) .expect_err("broken codex config must surface, not be swallowed as zero imports"); let message = err.to_string(); assert!( message.contains("codex"), "aggregated error should name the failing app, got: {message}" ); // Codex 的失败不阻断 Claude:alpha 应已入库并启用 Claude let servers = state.db.get_all_mcp_servers().expect("get all mcp servers"); let entry = servers .get("alpha") .expect("claude server imported despite codex failure"); assert!( entry.apps.claude, "imported server should have Claude app enabled" ); } #[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, grokbuild: false, opencode: false, hermes: 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, grokbuild: false, opencode: false, hermes: 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, grokbuild: false, opencode: false, hermes: 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, grokbuild: false, opencode: false, hermes: 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, grokbuild: false, opencode: false, hermes: 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, grokbuild: false, opencode: false, hermes: 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" ); } #[test] fn explicit_default_claude_dir_keeps_default_split_mcp_path() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let home = ensure_test_home(); let claude_dir = home.join(".claude"); fs::create_dir_all(&claude_dir).expect("create explicit default claude dir"); update_settings(AppSettings { claude_config_dir: Some(claude_dir.to_string_lossy().to_string()), ..AppSettings::default() }) .expect("set explicit default claude config dir"); assert_eq!( get_claude_mcp_path(), home.join(".claude.json"), "explicit default Claude dir should keep Claude Code's split MCP path" ); let state = create_test_state().expect("create test state"); McpService::upsert_server( &state, McpServer { id: "claude-default".to_string(), name: "Claude Default".to_string(), server: json!({ "type": "stdio", "command": "echo" }), apps: McpApps { claude: true, codex: false, gemini: false, grokbuild: false, opencode: false, hermes: false, }, description: None, homepage: None, docs: None, tags: Vec::new(), }, ) .expect("sync default Claude MCP"); assert!( home.join(".claude.json").exists(), "default split MCP file should be written at home/.claude.json" ); assert!( !claude_dir.join(".claude.json").exists(), "explicit default dir should not use nested .claude/.claude.json" ); } #[test] fn custom_claude_dir_writes_mcp_inside_config_dir() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let home = ensure_test_home(); let custom_dir = home.join("profiles").join(".claude"); fs::create_dir_all(&custom_dir).expect("create custom claude dir"); update_settings(AppSettings { claude_config_dir: Some(custom_dir.to_string_lossy().to_string()), ..AppSettings::default() }) .expect("set custom claude config dir"); let expected_mcp_path = custom_dir.join(".claude.json"); assert_eq!( get_claude_mcp_path(), expected_mcp_path, "custom Claude dir should keep MCP state inside the config dir" ); let state = create_test_state().expect("create test state"); McpService::upsert_server( &state, McpServer { id: "claude-custom".to_string(), name: "Claude Custom".to_string(), server: json!({ "type": "stdio", "command": "echo" }), apps: McpApps { claude: true, codex: false, gemini: false, grokbuild: false, opencode: false, hermes: false, }, description: None, homepage: None, docs: None, tags: Vec::new(), }, ) .expect("sync custom Claude MCP"); assert!( expected_mcp_path.exists(), "custom Claude MCP file should be written inside custom dir" ); assert!( !home.join("profiles").join(".claude.json").exists(), "custom Claude dir should not write sibling .claude.json" ); } #[test] fn custom_claude_dir_sync_does_not_copy_default_profile() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let home = ensure_test_home(); let home_mcp_path = home.join(".claude.json"); let default_profile = json!({ "hasCompletedOnboarding": true, "projects": { "/home-project": { "hasTrustDialogAccepted": true } }, "mcpServers": { "home-only": { "type": "stdio", "command": "home-command" } }, "profileSentinel": "home-profile" }); let default_profile_text = serde_json::to_string_pretty(&default_profile).expect("serialize default profile"); fs::write(&home_mcp_path, &default_profile_text).expect("seed default Claude profile"); let custom_dir = home.join("profiles").join("work").join(".claude"); fs::create_dir_all(&custom_dir).expect("create custom claude dir"); update_settings(AppSettings { claude_config_dir: Some(custom_dir.to_string_lossy().to_string()), ..AppSettings::default() }) .expect("set custom claude config dir"); let expected_mcp_path = custom_dir.join(".claude.json"); assert_eq!( get_claude_mcp_path(), expected_mcp_path, "custom Claude dir should use nested .claude.json" ); assert!( !expected_mcp_path.exists(), "custom profile should start without a live MCP file" ); let state = create_test_state().expect("create test state"); McpService::upsert_server( &state, McpServer { id: "custom-only".to_string(), name: "Custom Only".to_string(), server: json!({ "type": "stdio", "command": "custom-command" }), apps: McpApps { claude: true, codex: false, gemini: false, grokbuild: false, opencode: false, hermes: false, }, description: None, homepage: None, docs: None, tags: Vec::new(), }, ) .expect("sync custom Claude MCP"); let text = fs::read_to_string(&expected_mcp_path).expect("read custom Claude MCP"); let value: serde_json::Value = serde_json::from_str(&text).expect("parse custom Claude MCP"); let servers = value .get("mcpServers") .and_then(|v| v.as_object()) .expect("custom profile should contain mcpServers"); assert!( servers.contains_key("custom-only"), "custom profile should contain DB-managed Claude server" ); assert!( !servers.contains_key("home-only"), "custom profile should not inherit default profile MCP servers" ); assert!( value.get("hasCompletedOnboarding").is_none(), "custom profile should not inherit onboarding state" ); assert!( value.get("projects").is_none(), "custom profile should not inherit project trust state" ); assert!( value.get("profileSentinel").is_none(), "custom profile should not inherit unrelated default profile fields" ); assert_eq!( fs::read_to_string(&home_mcp_path).expect("reread default Claude profile"), default_profile_text, "default Claude profile should remain unchanged" ); } #[test] fn custom_claude_dir_read_only_mcp_queries_do_not_create_profile() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let home = ensure_test_home(); let home_mcp_path = home.join(".claude.json"); fs::write( &home_mcp_path, serde_json::to_string_pretty(&json!({ "mcpServers": { "home-only": { "type": "stdio", "command": "home-command" } }, "profileSentinel": "home-profile" })) .expect("serialize default profile"), ) .expect("seed default Claude profile"); let custom_dir = home.join("profiles").join("work").join(".claude"); fs::create_dir_all(&custom_dir).expect("create custom claude dir"); update_settings(AppSettings { claude_config_dir: Some(custom_dir.to_string_lossy().to_string()), ..AppSettings::default() }) .expect("set custom claude config dir"); let expected_mcp_path = custom_dir.join(".claude.json"); assert!( !expected_mcp_path.exists(), "custom profile should start without a live MCP file" ); let status = futures::executor::block_on(get_claude_mcp_status()).expect("get Claude MCP status"); assert_eq!( status.user_config_path, expected_mcp_path.to_string_lossy(), "status should report the custom profile MCP path" ); assert!( !status.user_config_exists, "status should report missing custom profile MCP file" ); let text = futures::executor::block_on(read_claude_mcp_config()).expect("read Claude MCP config"); assert_eq!(text, None, "missing custom profile should read as None"); assert!( !expected_mcp_path.exists(), "read-only MCP queries should not copy or create the custom profile" ); } #[test] fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() { 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, serde_json::to_string_pretty(&json!({ "mcpServers": { "managed-disabled": { "type": "stdio", "command": "echo" }, "external-only": { "type": "stdio", "command": "external" } } })) .expect("serialize claude mcp"), ) .expect("seed claude mcp"); let state = create_test_state().expect("create test state"); state .db .save_mcp_server(&McpServer { id: "managed-disabled".to_string(), name: "Managed Disabled".to_string(), server: json!({ "type": "stdio", "command": "echo" }), apps: McpApps { claude: false, codex: false, gemini: false, grokbuild: false, opencode: false, hermes: false, }, description: None, homepage: None, docs: None, tags: Vec::new(), }) .expect("save disabled server"); state .db .save_mcp_server(&McpServer { id: "managed-enabled".to_string(), name: "Managed Enabled".to_string(), server: json!({ "type": "stdio", "command": "managed" }), apps: McpApps { claude: true, codex: false, gemini: false, grokbuild: false, opencode: false, hermes: false, }, description: None, homepage: None, docs: None, tags: Vec::new(), }) .expect("save enabled server"); McpService::sync_all_enabled(&state).expect("reconcile mcp"); let text = fs::read_to_string(&mcp_path).expect("read claude mcp"); let value: serde_json::Value = serde_json::from_str(&text).expect("parse claude mcp"); let servers = value .get("mcpServers") .and_then(|entry| entry.as_object()) .expect("mcpServers object"); assert!( !servers.contains_key("managed-disabled"), "DB-known disabled server should be removed from live config" ); assert!( servers.contains_key("managed-enabled"), "DB-known enabled server should be present in live config" ); assert!( servers.contains_key("external-only"), "live entries unknown to DB should be preserved" ); }