refactor(mcp): preserve TOML formatting when syncing to Codex

Switch from `toml` to `toml_edit` crate for incremental TOML editing,
preserving user-written comments, whitespace, and key ordering in
Codex config.toml.

Changes:
- Add toml_edit 0.22 dependency for preserving-style TOML editing
- Refactor sync_enabled_to_codex() to use toml_edit::DocumentMut API
- Implement smart style detection: inherit existing mcp.servers or
  mcp_servers style, with fallback to sensible defaults
- Add deduplication logic to prevent both styles coexisting
- Add tests for format preservation and style inheritance
- Fix unused_mut and nonminimal_bool compiler warnings
- Apply cargo fmt to all modified files

Benefits:
- User comments and formatting are no longer lost during sync
- Respects user's preferred TOML structure (nested vs toplevel)
- Non-MCP fields in config.toml remain untouched
- Minimal surprise principle: only modifies necessary sections

Testing:
- All 47 tests passing (unit + integration)
- Clippy clean (0 warnings, 0 errors)
- Release build successful
- New tests verify comment preservation and style detection
This commit is contained in:
Jason
2025-10-29 23:52:46 +08:00
parent 590be4e136
commit 08f480ec94
5 changed files with 214 additions and 120 deletions

View File

@@ -573,167 +573,148 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
/// - 仅更新 `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::<TomlTable>(&base_text)
base_text
.parse::<DocumentMut>()
.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::<Vec<_>>();
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.jsontoml_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(())
}