mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
fix(codex): move common-config TOML merge off smol-toml to backend toml_edit
updateTomlCommonConfigSnippet re-serialized the whole document through
smol-toml (parse -> deepMerge -> stringify): comments dropped, keys
reordered, and empty parent table headers synthesized -- the
long-standing "config.toml keeps getting reordered" symptom (audit C5,
introduced in 083e48bf).
Replace it with a backend command backed by the same
merge_toml_table_like / remove_toml_table_like used when writing live
configs, so the form preview and the live write share one merge
semantic and user formatting survives edit-time merges. The frontend
sync helper is deleted outright to keep the pattern from coming back.
Making the form operations async exposes them to interleaving, so a
result is discarded unless it is still current when it lands:
- a per-hook sequence number (last operation wins) covers rapid
toggle/save races where an earlier merge resolves after a later
removal and would flip the switch back;
- a config-baseline check covers the user hand-editing the TOML while
a merge is in flight -- the stale result must not clobber their edit.
The checkbox state self-heals via the existing inference effect.
Regression tests pin both by resolving a suspended merge after a newer
operation / an external edit, plus backend tests locking comment and
key-order preservation, scalar override, and value-matched removal.
This commit is contained in:
@@ -275,6 +275,23 @@ pub async fn get_common_config_snippet(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 对前端编辑器里的 config.toml 文本做通用配置片段的合并/剥离。
|
||||
/// 放后端是为了走 toml_edit(保注释、保键序);前端 smol-toml 的
|
||||
/// 整文档重序列化会破坏用户手写格式。
|
||||
#[tauri::command]
|
||||
pub async fn update_toml_common_config_snippet(
|
||||
config_toml: String,
|
||||
snippet_toml: String,
|
||||
enabled: bool,
|
||||
) -> Result<String, String> {
|
||||
crate::services::provider::update_toml_common_config_snippet(
|
||||
&config_toml,
|
||||
&snippet_toml,
|
||||
enabled,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_common_config_snippet(
|
||||
app_type: String,
|
||||
|
||||
@@ -1215,6 +1215,7 @@ pub fn run() {
|
||||
commands::set_claude_common_config_snippet,
|
||||
commands::get_common_config_snippet,
|
||||
commands::set_common_config_snippet,
|
||||
commands::update_toml_common_config_snippet,
|
||||
commands::extract_common_config_snippet,
|
||||
commands::read_live_provider_settings,
|
||||
commands::get_settings,
|
||||
|
||||
@@ -306,6 +306,40 @@ fn remove_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 前端表单勾选/取消"使用通用配置"时,对编辑器里的 config.toml 文本做
|
||||
/// 结构化合并/剥离。必须在后端用 toml_edit 做:前端 smol-toml 只能
|
||||
/// parse → merge → 整文档重序列化,注释全丢、键序重排,还会生成多余的
|
||||
/// 空父表头(如 `[model_providers]`)。
|
||||
pub fn update_toml_common_config_snippet(
|
||||
config_toml: &str,
|
||||
snippet_toml: &str,
|
||||
enabled: bool,
|
||||
) -> Result<String, AppError> {
|
||||
let trimmed = snippet_toml.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(config_toml.to_string());
|
||||
}
|
||||
|
||||
let mut target_doc = if config_toml.trim().is_empty() {
|
||||
DocumentMut::new()
|
||||
} else {
|
||||
config_toml
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?
|
||||
};
|
||||
let source_doc = trimmed
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("Invalid Codex common config snippet: {e}")))?;
|
||||
|
||||
if enabled {
|
||||
merge_toml_table_like(target_doc.as_table_mut(), source_doc.as_table());
|
||||
} else {
|
||||
remove_toml_table_like(target_doc.as_table_mut(), source_doc.as_table());
|
||||
}
|
||||
|
||||
Ok(target_doc.to_string())
|
||||
}
|
||||
|
||||
fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: &str) -> bool {
|
||||
let trimmed = snippet.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -1678,6 +1712,71 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// C5 回归锁:前端表单的合并/剥离必须走 toml_edit 文档模型。
|
||||
/// smol-toml 的 parse→merge→stringify 整文档重序列化会丢注释、
|
||||
/// 按字母序重排键、并为 dotted 表生成多余的空父表头。
|
||||
#[test]
|
||||
fn update_toml_common_config_snippet_preserves_comments_and_key_order() {
|
||||
// 刻意非字母序的键序 + 注释,模拟用户手写格式
|
||||
let config = r#"# my precious comment
|
||||
model = "gpt-5.5"
|
||||
model_provider = "aprov"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.aprov]
|
||||
# provider comment
|
||||
name = "A Prov"
|
||||
base_url = "https://a.example/v1"
|
||||
"#;
|
||||
let snippet = "[tui]\nnotifications = true\n";
|
||||
|
||||
let merged = update_toml_common_config_snippet(config, snippet, true).unwrap();
|
||||
assert!(merged.contains("# my precious comment"));
|
||||
assert!(merged.contains("# provider comment"));
|
||||
let model_pos = merged.find("model = ").unwrap();
|
||||
let provider_pos = merged.find("model_provider = ").unwrap();
|
||||
let disable_pos = merged.find("disable_response_storage").unwrap();
|
||||
assert!(
|
||||
model_pos < provider_pos && provider_pos < disable_pos,
|
||||
"merge must not reorder user keys, got: {merged}"
|
||||
);
|
||||
assert!(merged.contains("[tui]"));
|
||||
assert!(merged.contains("notifications = true"));
|
||||
assert!(
|
||||
!merged.contains("[model_providers]\n"),
|
||||
"merge must not synthesize an empty parent table header, got: {merged}"
|
||||
);
|
||||
|
||||
let removed = update_toml_common_config_snippet(&merged, snippet, false).unwrap();
|
||||
assert!(!removed.contains("[tui]"), "snippet keys must be stripped");
|
||||
assert!(removed.contains("# my precious comment"));
|
||||
assert!(removed.contains("disable_response_storage = true"));
|
||||
}
|
||||
|
||||
/// 合并时标量=片段覆盖供应商值(与 Claude 侧 deepMerge 一致);
|
||||
/// 剥离按值匹配:用户改过的值不删(与 strip 路径的
|
||||
/// toml_value_is_subset 语义一致)。
|
||||
#[test]
|
||||
fn update_toml_common_config_snippet_scalar_override_and_value_matched_removal() {
|
||||
let snippet = "[tui]\nnotifications = true\n";
|
||||
|
||||
let merged =
|
||||
update_toml_common_config_snippet("[tui]\nnotifications = false\n", snippet, true)
|
||||
.unwrap();
|
||||
assert!(
|
||||
merged.contains("notifications = true"),
|
||||
"snippet scalar should override provider value, got: {merged}"
|
||||
);
|
||||
|
||||
let removed =
|
||||
update_toml_common_config_snippet("[tui]\nnotifications = false\n", snippet, false)
|
||||
.unwrap();
|
||||
assert!(
|
||||
removed.contains("notifications = false"),
|
||||
"user-modified value must survive removal, got: {removed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() {
|
||||
let settings = json!({
|
||||
|
||||
@@ -25,6 +25,7 @@ pub use live::{
|
||||
import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live,
|
||||
import_opencode_providers_from_live, read_live_settings,
|
||||
should_import_default_config_on_startup, sync_current_to_live,
|
||||
update_toml_common_config_snippet,
|
||||
};
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
|
||||
Reference in New Issue
Block a user