From 9d7b645c1d4c8cd391cd6aad23e7f93b6ea7bcfc Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 10 Aug 2026 16:38:00 +0800 Subject: [PATCH] fix(core-config): preserve user YAML customizations Treat an existing core config as the startup source of truth instead of rebuilding it from the example template. Preserve arbitrary user-defined top-level fields, nested mappings, lists, provider settings, comments, and WebUI-installed plugin metadata while applying only GUI-managed settings. Support correctly indented insertion of deeply nested YAML mappings during upgrade migrations and add broad regression coverage. --- src-tauri/src/core_config/settings.rs | 7 +- src-tauri/src/core_config/yaml.rs | 178 ++++++++++++++++++++++---- src-tauri/src/tests/core_config.rs | 69 +++++++--- 3 files changed, 205 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index da1a2a4..ad7a879 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -178,8 +178,11 @@ pub(crate) fn merge_core_config_yaml( current: Option<&str>, config: &GuiConfigFile, ) -> Result { - let merged = merge_core_config_fields(template, current)?; - apply_gui_managed_settings(&merged, config) + let base = match current { + Some(current) => current.to_string(), + None => merge_core_config_fields(template, None)?, + }; + apply_gui_managed_settings(&base, config) } pub(crate) fn merge_core_config_fields( diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index 9b45561..b1a1724 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -243,34 +243,47 @@ pub(crate) fn render_yaml_value_changes( remaining_changes.push(change); } } - let mut missing_nested_groups: Vec<(String, Vec<(String, serde_norway::Value)>)> = Vec::new(); - for change in &remaining_changes { - if change.path.len() != 2 + let mut missing_nested_groups: Vec<(Vec, Vec<(String, serde_norway::Value)>)> = + Vec::new(); + let mut yaml_edit_changes = Vec::new(); + for change in remaining_changes { + if change.path.len() < 2 || yaml_value_at_path(original, &change.path).is_some() - || yaml_value_at_path(original, &change.path[..1]) + || yaml_value_at_path(original, &change.path[..change.path.len() - 1]) .and_then(serde_norway::Value::as_mapping) .is_none() { + yaml_edit_changes.push(change); continue; } - let section = change.path[0].clone(); - let entry = (change.path[1].clone(), change.value.clone()); + let parent_path = change.path[..change.path.len() - 1].to_vec(); + let entry = ( + change.path.last().cloned().unwrap_or_default(), + change.value, + ); if let Some((_, entries)) = missing_nested_groups .iter_mut() - .find(|(existing, _)| existing == §ion) + .find(|(existing, _)| existing == &parent_path) { entries.push(entry); } else { - missing_nested_groups.push((section, vec![entry])); + missing_nested_groups.push((parent_path, vec![entry])); } } - for (section, entries) in missing_nested_groups { + for (parent_path, entries) in missing_nested_groups { if let Some(updated_content) = - insert_yaml_block_mapping_values(&editable_content, §ion, &entries)? + insert_yaml_block_mapping_values_at_path(&editable_content, &parent_path, &entries)? { editable_content = updated_content; + } else { + for (key, value) in entries { + let mut path = parent_path.clone(); + path.push(key); + yaml_edit_changes.push(YamlValueChange { path, value }); + } } } + remaining_changes = yaml_edit_changes; let file = editable_content .parse::() .map_err(|err| format!("解析可编辑内核配置失败: {err}"))?; @@ -287,11 +300,77 @@ pub(crate) fn render_yaml_value_changes( let validated = serde_norway::from_str::(&rendered) .map_err(|err| format!("验证更新后的内核配置失败: {err}"))?; if validated != *updated { - return Err("更新后的内核配置与预期值不一致,已拒绝写入".to_string()); + let path = first_yaml_mismatch_path(updated, &validated, &mut Vec::new()) + .unwrap_or_else(|| "".to_string()); + return Err(format!( + "更新后的内核配置与预期值不一致(路径: {path}),已拒绝写入" + )); } Ok(rendered) } +pub(crate) fn first_yaml_mismatch_path( + expected: &serde_norway::Value, + actual: &serde_norway::Value, + path: &mut Vec, +) -> Option { + if expected == actual { + return None; + } + match (expected, actual) { + ( + serde_norway::Value::Mapping(expected_mapping), + serde_norway::Value::Mapping(actual_mapping), + ) => { + for (key, expected_value) in expected_mapping { + let key_name = key + .as_str() + .map(str::to_string) + .unwrap_or_else(|| "".to_string()); + path.push(key_name); + let mismatch = actual_mapping + .get(key) + .and_then(|actual_value| { + first_yaml_mismatch_path(expected_value, actual_value, path) + }) + .or_else(|| (!actual_mapping.contains_key(key)).then(|| path.join("."))); + path.pop(); + if mismatch.is_some() { + return mismatch; + } + } + for key in actual_mapping.keys() { + if !expected_mapping.contains_key(key) { + let key = key.as_str().unwrap_or(""); + return Some(if path.is_empty() { + key.to_string() + } else { + format!("{}.{}", path.join("."), key) + }); + } + } + Some(path.join(".")) + } + ( + serde_norway::Value::Sequence(expected_values), + serde_norway::Value::Sequence(actual_values), + ) => { + for (index, (expected_value, actual_value)) in + expected_values.iter().zip(actual_values).enumerate() + { + path.push(format!("[{index}]")); + let mismatch = first_yaml_mismatch_path(expected_value, actual_value, path); + path.pop(); + if mismatch.is_some() { + return mismatch; + } + } + Some(format!("{}[length]", path.join("."))) + } + _ => Some(path.join(".")), + } +} + pub(crate) fn normalize_nested_yaml_comment_indentation(content: &str) -> String { let lines = content.split_inclusive('\n').collect::>(); let mut normalized = String::with_capacity(content.len()); @@ -394,36 +473,48 @@ pub(crate) fn yaml_value_at_path<'a>( }) } -pub(crate) fn insert_yaml_block_mapping_values( +pub(crate) fn insert_yaml_block_mapping_values_at_path( content: &str, - section: &str, + parent_path: &[String], entries: &[(String, serde_norway::Value)], ) -> Result, String> { let mut offset = 0; - let mut section_end = None; + let mut parent_end = None; + let mut parent_indent = 0; let mut child_indent = None; + let mut mapping_stack: Vec<(usize, String)> = Vec::new(); for line in content.split_inclusive('\n') { let body = line.trim_end_matches(['\r', '\n']); let trimmed = body.trim_start_matches([' ', '\t']); let indent = body.len().saturating_sub(trimmed.len()); - if section_end.is_none() { - let section_header = trimmed - .split_once('#') - .map(|(value, _)| value) - .unwrap_or(trimmed) - .trim_end(); - if indent == 0 && section_header == format!("{section}:") { - section_end = Some(offset + line.len()); + if parent_end.is_none() { + if let Some(key) = yaml_block_mapping_key(trimmed) { + while mapping_stack + .last() + .is_some_and(|(ancestor_indent, _)| *ancestor_indent >= indent) + { + mapping_stack.pop(); + } + mapping_stack.push((indent, key)); + if mapping_stack.len() == parent_path.len() + && mapping_stack + .iter() + .zip(parent_path) + .all(|((_, actual), expected)| actual == expected) + { + parent_end = Some(offset + line.len()); + parent_indent = indent; + } } } else if !trimmed.is_empty() && !trimmed.starts_with('#') { - if indent == 0 { + if indent <= parent_indent { break; } child_indent.get_or_insert(indent); } offset += line.len(); } - let Some(section_end) = section_end else { + let Some(parent_end) = parent_end else { return Ok(None); }; let newline = if content.contains("\r\n") { @@ -431,24 +522,55 @@ pub(crate) fn insert_yaml_block_mapping_values( } else { "\n" }; - let indent = " ".repeat(child_indent.unwrap_or(2)); + let indent = " ".repeat(child_indent.unwrap_or(parent_indent + 2)); let mut insertion = String::new(); for (key, value) in entries { let value = serde_json::to_string(value) .map_err(|error| format!("序列化内核配置值失败: {error}"))?; insertion.push_str(&indent); - insertion.push_str(key); + insertion.push_str(&render_yaml_mapping_key(key)); insertion.push_str(": "); insertion.push_str(&value); insertion.push_str(newline); } let mut updated = String::with_capacity(content.len() + insertion.len()); - updated.push_str(&content[..section_end]); + updated.push_str(&content[..parent_end]); updated.push_str(&insertion); - updated.push_str(&content[section_end..]); + updated.push_str(&content[parent_end..]); Ok(Some(updated)) } +pub(crate) fn yaml_block_mapping_key(line: &str) -> Option { + let header = line + .split_once('#') + .map(|(value, _)| value) + .unwrap_or(line) + .trim_end(); + let key = header.strip_suffix(':')?.trim_end(); + if key.is_empty() { + return None; + } + if key.starts_with('"') && key.ends_with('"') { + return serde_json::from_str(key).ok(); + } + if key.starts_with('\'') && key.ends_with('\'') { + return Some(key[1..key.len() - 1].replace("''", "'")); + } + Some(key.to_string()) +} + +pub(crate) fn render_yaml_mapping_key(key: &str) -> String { + if !key.is_empty() + && key + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + { + key.to_string() + } else { + serde_json::to_string(key).unwrap_or_else(|_| key.to_string()) + } +} + pub(crate) fn replace_yaml_sequence_value( content: &str, path: &[String], diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index 4f82f13..04745c6 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -708,9 +708,9 @@ fn unchanged_yaml_is_not_written_again() { } #[test] -fn startup_merge_uses_template_and_preserves_current_values() { +fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { let template = "# Current release template\nhost: \"\" # template bind address\nport: 8317\n\n# Client authentication\napi-keys:\n - template-key\n\n# Plugin runtime\nplugins:\n enabled: false # plugin switch\n\n# Credential routing\nrouting:\n strategy: round-robin # routing switch\n\n# New release option\nnew-option: true\nnested:\n # Nested template comment\n keep: template\n added: from-template\nlist:\n - template-item\n"; - let current = "host: 127.0.0.1\nport: 9000\nnested:\n keep: current\n current-only: retained\nlist:\n - current-a\n - current-b\nextra: true\n"; + let current = "# User-edited configuration\nhost: 127.0.0.1\nport: 9000\n# User-owned nested values\nnested:\n keep: current\n current-only: retained\nlist:\n - current-a\n - current-b\nextra: true\ncustom-provider:\n base-url: https://example.com/v1\n headers:\n X-Custom-Header: custom-value\n models:\n - name: custom-model\n aliases: [custom-a, custom-b]\nplugins:\n custom-runtime-options:\n sandbox: strict\n environment:\n CUSTOM_FLAG: enabled\nrouting:\n custom-rules:\n - match: custom-*\n target: custom-provider\nremote-management:\n custom-dashboard-option: retained\n"; let config = GuiConfigFile { locale: "zh-CN".to_string(), port: 9527, @@ -739,11 +739,12 @@ fn startup_merge_uses_template_and_preserves_current_values() { routing_session_affinity_ttl: "1h".to_string(), }; let merged = merge_core_config_yaml(template, Some(current), &config).unwrap(); + let current_document = serde_norway::from_str::(current).unwrap(); - assert!(merged.contains("# Current release template")); - assert!(merged.contains("# template bind address"), "{merged}"); - assert!(merged.contains("# New release option")); - assert!(merged.contains("# Nested template comment")); + assert!(merged.contains("# User-edited configuration")); + assert!(merged.contains("# User-owned nested values")); + assert!(!merged.contains("# Current release template")); + assert!(!merged.contains("# New release option")); let document = serde_norway::from_str::(&merged).unwrap(); assert_eq!( @@ -759,27 +760,57 @@ fn startup_merge_uses_template_and_preserves_current_values() { assert_eq!(document["routing"]["session-affinity"], true); assert_eq!(document["routing"]["session-affinity-ttl"], "1h"); assert_eq!(document["usage-statistics-enabled"], false); - assert_eq!(document["new-option"], serde_norway::Value::Bool(true)); + assert!(document.get("new-option").is_none()); + assert_eq!(document["nested"], current_document["nested"]); + assert!(document["nested"].get("added").is_none()); + for key in ["list", "extra", "custom-provider"] { + assert_eq!(document[key], current_document[key], "user field {key}"); + } assert_eq!( - document["nested"]["keep"], - serde_norway::Value::String("current".to_string()) + document["plugins"]["custom-runtime-options"], + current_document["plugins"]["custom-runtime-options"] ); assert_eq!( - document["nested"]["added"], - serde_norway::Value::String("from-template".to_string()) + document["routing"]["custom-rules"], + current_document["routing"]["custom-rules"] ); assert_eq!( - document["nested"]["current-only"], - serde_norway::Value::String("retained".to_string()) + document["remote-management"]["custom-dashboard-option"], + current_document["remote-management"]["custom-dashboard-option"] ); - assert_eq!(document["extra"], serde_norway::Value::Bool(true)); +} + +#[test] +fn startup_merge_preserves_plugin_store_config_written_by_core() { + let template = "host: \"\"\nport: 8317\nauth-dir: ~/.cli-proxy-api\napi-keys:\n - template-key\nremote-management:\n secret-key: \"\"\nusage-statistics-enabled: true\nplugins:\n enabled: false\n dir: plugins\n configs:\n example:\n enabled: true\n priority: 1\nrouting:\n strategy: round-robin\n session-affinity: false\n session-affinity-ttl: \"\"\nproxy-url: \"\"\ncommercial-mode: false\n"; + let current = "host: 127.0.0.1\nport: 8317\nauth-dir: ~/.cli-proxy-api\napi-keys:\n - '123456'\nremote-management:\n secret-key: \"\"\nusage-statistics-enabled: true\nplugins:\n enabled: true\n dir: plugins\n configs:\n model-fallback-router:\n enabled: true\n store:\n id: model-fallback-router\n name: Model Fallback Router\n description: Retries matching model requests through configured fallback model names when the primary model fails with quota, rate-limit, transport, or configured HTTP status errors.\n author: thebtf\n version: 0.2.0\n release-tag: v0.2.0\n repository: https://github.com/thebtf/cpa-model-fallback-router\n tags:\n - Router\n - Model Router\n - Fallback\n install:\n type: github-release\nrouting:\n strategy: round-robin\n session-affinity: false\n session-affinity-ttl: \"\"\nproxy-url: \"\"\n"; + let config = GuiConfigFile { + host: "0.0.0.0".to_string(), + allow_lan: true, + plugins_enabled: true, + ..GuiConfigFile::default() + }; + + let merged = merge_core_config_yaml(template, Some(current), &config) + .unwrap_or_else(|error| panic!("plugin config merge failed: {error}")); + let document = serde_norway::from_str::(&merged).unwrap(); + let current_document = serde_norway::from_str::(current).unwrap(); + + assert_eq!(document["host"], "0.0.0.0"); assert_eq!( - document["list"], - serde_norway::Value::Sequence(vec![ - serde_norway::Value::String("current-a".to_string()), - serde_norway::Value::String("current-b".to_string()), - ]) + document["plugins"]["configs"]["model-fallback-router"], + current_document["plugins"]["configs"]["model-fallback-router"] ); + assert_eq!( + document["plugins"]["configs"]["model-fallback-router"]["store"]["version"], + "0.2.0" + ); + assert_eq!( + document["plugins"]["configs"]["model-fallback-router"]["store"]["install"]["type"], + "github-release" + ); + assert!(document.get("commercial-mode").is_none()); + assert!(document["plugins"]["configs"].get("example").is_none()); } #[test]