diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ca88e5a..fb70ac3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -588,6 +588,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "toml 0.8.2", + "toml_edit 0.22.27", ] [[package]] @@ -1573,7 +1574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ "heck 0.4.1", - "proc-macro-crate 2.0.2", + "proc-macro-crate 2.0.0", "proc-macro-error", "proc-macro2", "quote", @@ -3110,11 +3111,10 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "2.0.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" dependencies = [ - "toml_datetime 0.6.3", "toml_edit 0.20.2", ] @@ -4876,7 +4876,7 @@ checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", + "toml_datetime 0.6.11", "toml_edit 0.20.2", ] @@ -4897,9 +4897,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] @@ -4920,7 +4920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.11.4", - "toml_datetime 0.6.3", + "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -4933,10 +4933,22 @@ dependencies = [ "indexmap 2.11.4", "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", + "toml_datetime 0.6.11", "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.11.4", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.13", +] + [[package]] name = "toml_edit" version = "0.23.6" @@ -4958,6 +4970,12 @@ dependencies = [ "winnow 0.7.13", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.0.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4c53864..dc8dd69 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ tauri-plugin-dialog = "2" tauri-plugin-store = "2" dirs = "5.0" toml = "0.8" +toml_edit = "0.22" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } futures = "0.3" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4b37b46..443fd10 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -273,7 +273,7 @@ fn switch_provider_internal( Some(app_type_str.clone()), provider_id, ) - .map_err(AppError::Message)?; + .map_err(AppError::Message)?; // 切换成功后重新创建托盘菜单 if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index ff47643..a7139af 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -573,167 +573,148 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result /// - 仅更新 `mcp.servers` 或 `mcp_servers` 子表,保留 `mcp` 其它键 /// - 仅写入启用项;无启用项时清理对应子表 pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> { - use toml::{value::Value as TomlValue, Table as TomlTable}; + use toml_edit::{DocumentMut, Item, Table}; // 1) 收集启用项(Codex 维度) let enabled = collect_enabled_servers(&config.mcp.codex); - // 2) 读取现有 config.toml 并解析为 Table(允许空文件) + // 2) 读取现有 config.toml 文本;保持无效 TOML 的错误返回(不覆盖文件) let base_text = crate::codex_config::read_and_validate_codex_config_text()?; - let mut root: TomlTable = if base_text.trim().is_empty() { - TomlTable::new() + + // 3) 使用 toml_edit 解析(允许空文件) + let mut doc: DocumentMut = if base_text.trim().is_empty() { + DocumentMut::default() } else { - toml::from_str::(&base_text) + base_text + .parse::() .map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {}", e)))? }; - // 3) 写入 servers 表(支持 mcp.servers 与 mcp_servers;优先沿用已有风格,默认 mcp_servers) - let prefer_mcp_servers = root.get("mcp_servers").is_some() || root.get("mcp").is_none(); - if enabled.is_empty() { - // 无启用项:移除两种节点 - // 清除 mcp.servers,但保留其他 mcp 字段 - let mut should_drop_mcp = false; - if let Some(mcp_val) = root.get_mut("mcp") { - match mcp_val { - TomlValue::Table(tbl) => { - tbl.remove("servers"); - should_drop_mcp = tbl.is_empty(); - } - _ => should_drop_mcp = true, - } - } - if should_drop_mcp { - root.remove("mcp"); - } + enum Target { + McpServers, // 顶层 mcp_servers + McpDotServers, // mcp.servers + } - // 清除顶层 mcp_servers - root.remove("mcp_servers"); + // 4) 选择目标风格:优先沿用既有子表;其次在 mcp 表下新建;最后退回顶层 mcp_servers + let has_mcp_dot_servers = doc + .get("mcp") + .and_then(|m| m.get("servers")) + .and_then(|s| s.as_table_like()) + .is_some(); + let has_mcp_servers = doc + .get("mcp_servers") + .and_then(|s| s.as_table_like()) + .is_some(); + let mcp_is_table = doc.get("mcp").and_then(|m| m.as_table_like()).is_some(); + + let target = if has_mcp_dot_servers { + Target::McpDotServers + } else if has_mcp_servers { + Target::McpServers + } else if mcp_is_table { + Target::McpDotServers } else { - let mut servers_tbl = TomlTable::new(); + Target::McpServers + }; - for (id, spec) in enabled.iter() { - let mut s = TomlTable::new(); - - // 类型(缺省视为 stdio) + // 构造目标 servers 表(稳定的键顺序) + let build_servers_table = || -> Table { + let mut servers = Table::new(); + let mut ids: Vec<_> = enabled.keys().cloned().collect(); + ids.sort(); + for id in ids { + let spec = enabled.get(&id).expect("spec must exist"); + let mut t = Table::new(); let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio"); - s.insert("type".into(), TomlValue::String(typ.to_string())); - + t["type"] = toml_edit::value(typ); match typ { "stdio" => { - let cmd = spec - .get("command") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - s.insert("command".into(), TomlValue::String(cmd)); - + let cmd = spec.get("command").and_then(|v| v.as_str()).unwrap_or(""); + t["command"] = toml_edit::value(cmd); if let Some(args) = spec.get("args").and_then(|v| v.as_array()) { - let arr = args - .iter() - .filter_map(|x| x.as_str()) - .map(|x| TomlValue::String(x.to_string())) - .collect::>(); - if !arr.is_empty() { - s.insert("args".into(), TomlValue::Array(arr)); + let mut arr_v = toml_edit::Array::default(); + for a in args.iter().filter_map(|x| x.as_str()) { + arr_v.push(a); + } + if !arr_v.is_empty() { + t["args"] = toml_edit::Item::Value(toml_edit::Value::Array(arr_v)); } } - if let Some(cwd) = spec.get("cwd").and_then(|v| v.as_str()) { if !cwd.trim().is_empty() { - s.insert("cwd".into(), TomlValue::String(cwd.to_string())); + t["cwd"] = toml_edit::value(cwd); } } - if let Some(env) = spec.get("env").and_then(|v| v.as_object()) { - let mut env_tbl = TomlTable::new(); + let mut env_tbl = Table::new(); for (k, v) in env.iter() { - if let Some(sv) = v.as_str() { - env_tbl.insert(k.clone(), TomlValue::String(sv.to_string())); + if let Some(s) = v.as_str() { + env_tbl[&k[..]] = toml_edit::value(s); } } if !env_tbl.is_empty() { - s.insert("env".into(), TomlValue::Table(env_tbl)); + t["env"] = Item::Table(env_tbl); } } } "http" => { - let url = spec - .get("url") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - s.insert("url".into(), TomlValue::String(url)); - + let url = spec.get("url").and_then(|v| v.as_str()).unwrap_or(""); + t["url"] = toml_edit::value(url); if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) { - let mut h_tbl = TomlTable::new(); + let mut h_tbl = Table::new(); for (k, v) in headers.iter() { - if let Some(sv) = v.as_str() { - h_tbl.insert(k.clone(), TomlValue::String(sv.to_string())); + if let Some(s) = v.as_str() { + h_tbl[&k[..]] = toml_edit::value(s); } } if !h_tbl.is_empty() { - s.insert("headers".into(), TomlValue::Table(h_tbl)); + t["headers"] = Item::Table(h_tbl); } } } _ => {} } - - servers_tbl.insert(id.clone(), TomlValue::Table(s)); + servers[&id[..]] = Item::Table(t); } + servers + }; - let servers_value = TomlValue::Table(servers_tbl.clone()); - - if prefer_mcp_servers { - root.insert("mcp_servers".into(), servers_value); - - // 若存在 mcp,则仅移除 servers 字段,保留其他键 - let mut should_drop_mcp = false; - if let Some(mcp_val) = root.get_mut("mcp") { - match mcp_val { - TomlValue::Table(tbl) => { + // 5) 应用更新:仅就地更新目标子表;避免改动其它键/注释/空白 + if enabled.is_empty() { + // 无启用项:移除两种 servers 表(如果存在),但保留 mcp 其它字段 + if let Some(mcp_item) = doc.get_mut("mcp") { + if let Some(tbl) = mcp_item.as_table_like_mut() { + tbl.remove("servers"); + } + } + doc.as_table_mut().remove("mcp_servers"); + } else { + let servers_tbl = build_servers_table(); + match target { + Target::McpDotServers => { + // 确保 mcp 为表 + if doc.get("mcp").and_then(|m| m.as_table_like()).is_none() { + doc["mcp"] = Item::Table(Table::new()); + } + doc["mcp"]["servers"] = Item::Table(servers_tbl); + // 去重:若存在顶层 mcp_servers,则移除以避免重复定义 + doc.as_table_mut().remove("mcp_servers"); + } + Target::McpServers => { + doc["mcp_servers"] = Item::Table(servers_tbl); + // 去重:若存在 mcp.servers,则移除该子表,保留 mcp 其它键 + if let Some(mcp_item) = doc.get_mut("mcp") { + if let Some(tbl) = mcp_item.as_table_like_mut() { tbl.remove("servers"); - should_drop_mcp = tbl.is_empty(); - } - _ => should_drop_mcp = true, - } - } - if should_drop_mcp { - root.remove("mcp"); - } - } else { - let mut inserted = false; - - if let Some(mcp_val) = root.get_mut("mcp") { - match mcp_val { - TomlValue::Table(tbl) => { - tbl.insert("servers".into(), TomlValue::Table(servers_tbl.clone())); - inserted = true; - } - _ => { - let mut mcp_tbl = TomlTable::new(); - mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl.clone())); - *mcp_val = TomlValue::Table(mcp_tbl); - inserted = true; } } } - - if !inserted { - let mut mcp_tbl = TomlTable::new(); - mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl)); - root.insert("mcp".into(), TomlValue::Table(mcp_tbl)); - } - - root.remove("mcp_servers"); } } - // 4) 序列化并写回 config.toml(仅改 TOML,不触碰 auth.json) - let new_text = toml::to_string(&TomlValue::Table(root)) - .map_err(|e| AppError::McpValidation(format!("序列化 config.toml 失败: {}", e)))?; + // 6) 写回(仅改 TOML,不触碰 auth.json);toml_edit 会尽量保留未改区域的注释/空白/顺序 + let new_text = doc.to_string(); let path = crate::codex_config::get_codex_config_path(); crate::config::write_text_file(&path, &new_text)?; - Ok(()) } diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 690512e..fcd4a82 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -179,6 +179,100 @@ fn sync_enabled_to_codex_writes_enabled_servers() { ); } +#[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" + ); + // 新增的 mcp_servers/或 mcp.servers 应存在并包含 echo + assert!( + text.contains("mcp_servers") || text.contains("[mcp.servers]"), + "one server table style should be present" + ); + assert!( + text.contains("echo") && text.contains("command = \"echo\""), + "echo server should be serialized" + ); +} + +#[test] +fn sync_enabled_to_codex_keeps_existing_style_mcp_dot_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 风格 + 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 风格 + assert!( + text.contains("[mcp.servers]"), + "should keep mcp.servers style" + ); + assert!( + !text.contains("mcp_servers"), + "should not switch to mcp_servers" + ); +} + #[test] fn sync_enabled_to_codex_removes_servers_when_none_enabled() { let _guard = test_mutex().lock().expect("acquire test mutex");