diff --git a/src-tauri/src/config_merge.rs b/src-tauri/src/config_merge.rs new file mode 100644 index 000000000..7fa5d3656 --- /dev/null +++ b/src-tauri/src/config_merge.rs @@ -0,0 +1,674 @@ +//! Configuration merge utilities for the common config redesign. +//! +//! This module provides functions for: +//! - `compute_final_config`: Merges common config (base) with custom config (override) +//! - `extract_difference`: Extracts custom parts from live config by comparing with common config +//! +//! Supports JSON (Claude, Gemini) and TOML (Codex) formats. + +// Allow dead code as this is a utility module with functions available for future use +#![allow(dead_code)] + +use serde_json::{Map, Value as JsonValue}; +use toml::Value as TomlValue; + +// ============================================================================ +// JSON Configuration Merge Functions +// ============================================================================ + +/// Deep merge two JSON objects where `source` overrides `target`. +/// +/// Merge rules: +/// - Nested objects: Recursive merge +/// - Arrays: Source completely replaces target (no element-level merge) +/// - Primitives: Source overrides target +/// - Null values: Do not override +fn deep_merge_json(target: &mut JsonValue, source: &JsonValue) { + // First check if both are objects without destructuring + let both_objects = + matches!(target, JsonValue::Object(_)) && matches!(source, JsonValue::Object(_)); + + if both_objects { + // Safe to destructure now since we know both are objects + if let (JsonValue::Object(target_map), JsonValue::Object(source_map)) = (target, source) { + for (key, source_value) in source_map { + if source_value.is_null() { + // Null doesn't override + continue; + } + match target_map.get_mut(key) { + Some(target_value) if target_value.is_object() && source_value.is_object() => { + // Nested object: recursive merge + deep_merge_json(target_value, source_value); + } + _ => { + // Other cases: source overrides + target_map.insert(key.clone(), source_value.clone()); + } + } + } + } + } else { + // Non-object: source overrides + *target = source.clone(); + } +} + +/// Compute final JSON config. +/// +/// Common config as base, custom config overrides (custom takes priority). +/// +/// # Arguments +/// * `custom_config` - Provider's custom configuration +/// * `common_config` - Common configuration snippet +/// * `enabled` - Whether common config is enabled +/// +/// # Returns +/// The merged final configuration as JSON value +pub fn compute_final_json_config( + custom_config: &JsonValue, + common_config: &JsonValue, + enabled: bool, +) -> JsonValue { + if !enabled { + return custom_config.clone(); + } + + // Validate both are objects + let common_obj = match common_config { + JsonValue::Object(m) if !m.is_empty() => m, + _ => return custom_config.clone(), + }; + + let custom_obj = match custom_config { + JsonValue::Object(_) => custom_config, + _ => return custom_config.clone(), + }; + + // Start with common config as base + let mut result = JsonValue::Object(common_obj.clone()); + + // Merge custom config on top (custom overrides common) + deep_merge_json(&mut result, custom_obj); + + result +} + +/// Compute final JSON config from strings. +/// +/// # Arguments +/// * `custom_config_json` - Custom configuration as JSON string +/// * `common_config_json` - Common configuration as JSON string +/// * `enabled` - Whether common config is enabled +/// +/// # Returns +/// Tuple of (final_config_json, error_message) +pub fn compute_final_json_config_str( + custom_config_json: &str, + common_config_json: &str, + enabled: bool, +) -> (String, Option) { + let custom_config: JsonValue = match serde_json::from_str(custom_config_json) { + Ok(v) => v, + Err(_) => { + return ( + custom_config_json.to_string(), + Some("Failed to parse custom config JSON".to_string()), + ) + } + }; + + let common_config: JsonValue = if common_config_json.trim().is_empty() { + JsonValue::Object(Map::new()) + } else { + match serde_json::from_str(common_config_json) { + Ok(v) => v, + Err(_) => { + return ( + custom_config_json.to_string(), + Some("Failed to parse common config JSON".to_string()), + ) + } + } + }; + + let final_config = compute_final_json_config(&custom_config, &common_config, enabled); + + match serde_json::to_string_pretty(&final_config) { + Ok(s) => (s, None), + Err(e) => ( + custom_config_json.to_string(), + Some(format!("Failed to serialize: {e}")), + ), + } +} + +/// Check if two JSON values are deeply equal. +fn json_deep_equal(a: &JsonValue, b: &JsonValue) -> bool { + match (a, b) { + (JsonValue::Null, JsonValue::Null) => true, + (JsonValue::Bool(a), JsonValue::Bool(b)) => a == b, + (JsonValue::Number(a), JsonValue::Number(b)) => a == b, + (JsonValue::String(a), JsonValue::String(b)) => a == b, + (JsonValue::Array(a), JsonValue::Array(b)) => { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).all(|(x, y)| json_deep_equal(x, y)) + } + (JsonValue::Object(a), JsonValue::Object(b)) => { + if a.len() != b.len() { + return false; + } + a.iter() + .all(|(k, v)| b.get(k).is_some_and(|bv| json_deep_equal(v, bv))) + } + _ => false, + } +} + +/// Extract difference between live config and common config. +/// +/// Extraction rules: +/// - Keys not in common config → include in custom config +/// - Keys in common config but with different values → include in custom config (user override) +/// - Keys in common config with same values → skip (avoid redundant storage) +/// +/// # Arguments +/// * `live_config` - Configuration read from live file +/// * `common_config` - Common configuration snippet +/// +/// # Returns +/// Tuple of (custom_config, has_common_keys) +pub fn extract_json_difference( + live_config: &JsonValue, + common_config: &JsonValue, +) -> (JsonValue, bool) { + let live_obj = match live_config { + JsonValue::Object(m) => m, + _ => return (live_config.clone(), false), + }; + + let common_obj = match common_config { + JsonValue::Object(m) => m, + _ => return (live_config.clone(), false), + }; + + let mut custom_config = Map::new(); + let mut has_common_keys = false; + + fn extract_recursive( + live: &Map, + common: &Map, + target: &mut Map, + has_common: &mut bool, + ) { + for (key, live_value) in live { + match common.get(key) { + None => { + // Case 1: Key not in common config, keep it + target.insert(key.clone(), live_value.clone()); + } + Some(common_value) => { + // Check if both are objects for nested handling + match (live_value, common_value) { + (JsonValue::Object(live_map), JsonValue::Object(common_map)) => { + // Case 2: Nested object, recurse + let mut nested = Map::new(); + extract_recursive(live_map, common_map, &mut nested, has_common); + if !nested.is_empty() { + target.insert(key.clone(), JsonValue::Object(nested)); + } else { + // Nested object matches common config + *has_common = true; + } + } + _ if !json_deep_equal(live_value, common_value) => { + // Case 3: Value different, keep it (user override) + target.insert(key.clone(), live_value.clone()); + } + _ => { + // Case 4: Value same, skip (avoid redundancy) + *has_common = true; + } + } + } + } + } + } + + extract_recursive( + live_obj, + common_obj, + &mut custom_config, + &mut has_common_keys, + ); + + (JsonValue::Object(custom_config), has_common_keys) +} + +/// Extract difference from JSON strings. +/// +/// # Returns +/// Tuple of (custom_config_json, has_common_keys, error_message) +pub fn extract_json_difference_str( + live_config_json: &str, + common_config_json: &str, +) -> (String, bool, Option) { + let live_config: JsonValue = match serde_json::from_str(live_config_json) { + Ok(v) => v, + Err(_) => { + return ( + live_config_json.to_string(), + false, + Some("Failed to parse live config JSON".to_string()), + ) + } + }; + + let common_config: JsonValue = if common_config_json.trim().is_empty() { + JsonValue::Object(Map::new()) + } else { + match serde_json::from_str(common_config_json) { + Ok(v) => v, + Err(_) => { + return ( + live_config_json.to_string(), + false, + Some("Failed to parse common config JSON".to_string()), + ) + } + } + }; + + let (custom_config, has_common_keys) = extract_json_difference(&live_config, &common_config); + + match serde_json::to_string_pretty(&custom_config) { + Ok(s) => (s, has_common_keys, None), + Err(e) => ( + live_config_json.to_string(), + false, + Some(format!("Failed to serialize: {e}")), + ), + } +} + +// ============================================================================ +// TOML Configuration Merge Functions +// ============================================================================ + +/// Deep merge two TOML tables where `source` overrides `target`. +fn deep_merge_toml(target: &mut TomlValue, source: &TomlValue) { + // First check if both are tables without destructuring + let both_tables = + matches!(target, TomlValue::Table(_)) && matches!(source, TomlValue::Table(_)); + + if both_tables { + // Safe to destructure now since we know both are tables + if let (TomlValue::Table(target_map), TomlValue::Table(source_map)) = (target, source) { + for (key, source_value) in source_map { + match target_map.get_mut(key) { + Some(target_value) if target_value.is_table() && source_value.is_table() => { + // Nested table: recursive merge + deep_merge_toml(target_value, source_value); + } + _ => { + // Other cases: source overrides + target_map.insert(key.clone(), source_value.clone()); + } + } + } + } + } else { + // Non-table: source overrides + *target = source.clone(); + } +} + +/// Compute final TOML config. +/// +/// # Arguments +/// * `custom_config` - Provider's custom TOML configuration +/// * `common_config` - Common TOML configuration snippet +/// * `enabled` - Whether common config is enabled +/// +/// # Returns +/// Tuple of (final_config_toml, error_message) +pub fn compute_final_toml_config_str( + custom_toml: &str, + common_toml: &str, + enabled: bool, +) -> (String, Option) { + if !enabled || common_toml.trim().is_empty() { + return (custom_toml.to_string(), None); + } + + // Check if common TOML has actual content (not just comments) + let common_has_content = common_toml.lines().any(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() && !trimmed.starts_with('#') + }); + + if !common_has_content { + return (custom_toml.to_string(), None); + } + + // Parse custom TOML + let custom_config: TomlValue = match custom_toml.parse() { + Ok(v) => v, + Err(_) if custom_toml.trim().is_empty() => TomlValue::Table(toml::map::Map::new()), + Err(e) => { + return ( + custom_toml.to_string(), + Some(format!("Failed to parse custom TOML: {e}")), + ) + } + }; + + // Parse common TOML + let common_config: TomlValue = match common_toml.parse() { + Ok(v) => v, + Err(e) => { + return ( + custom_toml.to_string(), + Some(format!("Failed to parse common TOML: {e}")), + ) + } + }; + + // Start with common config as base + let mut result = common_config; + + // Merge custom config on top + deep_merge_toml(&mut result, &custom_config); + + // Serialize back to TOML string + match toml::to_string_pretty(&result) { + Ok(s) => (s, None), + Err(e) => ( + custom_toml.to_string(), + Some(format!("Failed to serialize TOML: {e}")), + ), + } +} + +/// Check if two TOML values are deeply equal. +fn toml_deep_equal(a: &TomlValue, b: &TomlValue) -> bool { + match (a, b) { + (TomlValue::String(a), TomlValue::String(b)) => a == b, + (TomlValue::Integer(a), TomlValue::Integer(b)) => a == b, + (TomlValue::Float(a), TomlValue::Float(b)) => (a - b).abs() < f64::EPSILON, + (TomlValue::Boolean(a), TomlValue::Boolean(b)) => a == b, + (TomlValue::Datetime(a), TomlValue::Datetime(b)) => a == b, + (TomlValue::Array(a), TomlValue::Array(b)) => { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).all(|(x, y)| toml_deep_equal(x, y)) + } + (TomlValue::Table(a), TomlValue::Table(b)) => { + if a.len() != b.len() { + return false; + } + a.iter() + .all(|(k, v)| b.get(k).is_some_and(|bv| toml_deep_equal(v, bv))) + } + _ => false, + } +} + +/// Extract difference between live TOML config and common config. +/// +/// # Returns +/// Tuple of (custom_toml, has_common_keys, error_message) +pub fn extract_toml_difference_str( + live_toml: &str, + common_toml: &str, +) -> (String, bool, Option) { + if common_toml.trim().is_empty() { + return (live_toml.to_string(), false, None); + } + + // Check if common TOML has actual content + let common_has_content = common_toml.lines().any(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() && !trimmed.starts_with('#') + }); + + if !common_has_content { + return (live_toml.to_string(), false, None); + } + + // Parse live TOML + let live_config: TomlValue = match live_toml.parse() { + Ok(v) => v, + Err(_) if live_toml.trim().is_empty() => TomlValue::Table(toml::map::Map::new()), + Err(e) => { + return ( + live_toml.to_string(), + false, + Some(format!("Failed to parse live TOML: {e}")), + ) + } + }; + + // Parse common TOML + let common_config: TomlValue = match common_toml.parse() { + Ok(v) => v, + Err(e) => { + return ( + live_toml.to_string(), + false, + Some(format!("Failed to parse common TOML: {e}")), + ) + } + }; + + let live_table = match &live_config { + TomlValue::Table(m) => m, + _ => return (live_toml.to_string(), false, None), + }; + + let common_table = match &common_config { + TomlValue::Table(m) => m, + _ => return (live_toml.to_string(), false, None), + }; + + let mut custom_table = toml::map::Map::new(); + let mut has_common_keys = false; + + fn extract_recursive_toml( + live: &toml::map::Map, + common: &toml::map::Map, + target: &mut toml::map::Map, + has_common: &mut bool, + ) { + for (key, live_value) in live { + match common.get(key) { + None => { + target.insert(key.clone(), live_value.clone()); + } + Some(common_value) => match (live_value, common_value) { + (TomlValue::Table(live_map), TomlValue::Table(common_map)) => { + let mut nested = toml::map::Map::new(); + extract_recursive_toml(live_map, common_map, &mut nested, has_common); + if !nested.is_empty() { + target.insert(key.clone(), TomlValue::Table(nested)); + } else { + *has_common = true; + } + } + _ if !toml_deep_equal(live_value, common_value) => { + target.insert(key.clone(), live_value.clone()); + } + _ => { + *has_common = true; + } + }, + } + } + } + + extract_recursive_toml( + live_table, + common_table, + &mut custom_table, + &mut has_common_keys, + ); + + let custom_config = TomlValue::Table(custom_table); + + match toml::to_string_pretty(&custom_config) { + Ok(s) if s.trim().is_empty() => (String::new(), has_common_keys, None), + Ok(s) => (s, has_common_keys, None), + Err(e) => ( + live_toml.to_string(), + false, + Some(format!("Failed to serialize TOML: {e}")), + ), + } +} + +// ============================================================================ +// Unit Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_compute_final_json_config_disabled() { + let custom = json!({"a": 1, "b": 2}); + let common = json!({"c": 3}); + + let result = compute_final_json_config(&custom, &common, false); + assert_eq!(result, custom); + } + + #[test] + fn test_compute_final_json_config_enabled() { + let custom = json!({"a": 1, "b": 2}); + let common = json!({"b": 99, "c": 3}); + + let result = compute_final_json_config(&custom, &common, true); + + // custom overrides common, so b should be 2 + assert_eq!(result["a"], 1); + assert_eq!(result["b"], 2); // custom wins + assert_eq!(result["c"], 3); // from common + } + + #[test] + fn test_compute_final_json_config_nested() { + let custom = json!({ + "env": { + "API_KEY": "custom-key", + "CUSTOM_VAR": "value" + } + }); + let common = json!({ + "env": { + "API_KEY": "common-key", + "SHARED_VAR": "shared" + }, + "includeCoAuthoredBy": false + }); + + let result = compute_final_json_config(&custom, &common, true); + + assert_eq!(result["env"]["API_KEY"], "custom-key"); // custom wins + assert_eq!(result["env"]["CUSTOM_VAR"], "value"); // from custom + assert_eq!(result["env"]["SHARED_VAR"], "shared"); // from common + assert_eq!(result["includeCoAuthoredBy"], false); // from common + } + + #[test] + fn test_extract_json_difference() { + let live = json!({ + "env": { + "API_KEY": "my-key", + "SHARED_VAR": "shared" + }, + "includeCoAuthoredBy": false, + "custom_field": true + }); + let common = json!({ + "env": { + "SHARED_VAR": "shared" + }, + "includeCoAuthoredBy": false + }); + + let (custom, has_common) = extract_json_difference(&live, &common); + + // Should keep API_KEY (not in common) and custom_field + assert_eq!(custom["env"]["API_KEY"], "my-key"); + assert_eq!(custom["custom_field"], true); + // Should NOT have SHARED_VAR or includeCoAuthoredBy (same as common) + assert!(custom["env"].get("SHARED_VAR").is_none()); + assert!(custom.get("includeCoAuthoredBy").is_none()); + assert!(has_common); + } + + #[test] + fn test_extract_json_difference_with_override() { + let live = json!({ + "includeCoAuthoredBy": true, // Different from common! + "shared": "value" + }); + let common = json!({ + "includeCoAuthoredBy": false, + "shared": "value" + }); + + let (custom, has_common) = extract_json_difference(&live, &common); + + // Should keep includeCoAuthoredBy because value is different + assert_eq!(custom["includeCoAuthoredBy"], true); + // Should NOT have shared (same as common) + assert!(custom.get("shared").is_none()); + assert!(has_common); + } + + #[test] + fn test_compute_final_toml_config() { + let custom = r#" +model = "custom-model" +[custom_section] +key = "value" +"#; + let common = r#" +model = "common-model" +shared_key = "shared" +"#; + + let (result, error) = compute_final_toml_config_str(custom, common, true); + + assert!(error.is_none()); + assert!(result.contains("custom-model")); // custom wins + assert!(result.contains("shared_key")); // from common + } + + #[test] + fn test_extract_toml_difference() { + let live = r#" +model = "my-model" +shared_key = "shared" +[custom_section] +key = "value" +"#; + let common = r#" +shared_key = "shared" +"#; + + let (custom, has_common, error) = extract_toml_difference_str(live, common); + + assert!(error.is_none()); + assert!(custom.contains("model")); // not in common + assert!(custom.contains("custom_section")); // not in common + assert!(!custom.contains("shared_key")); // same as common + assert!(has_common); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06ee64564..c0dffebac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod claude_plugin; mod codex_config; mod commands; mod config; +mod config_merge; mod database; mod deeplink; mod error; diff --git a/src/utils/configMerge.ts b/src/utils/configMerge.ts new file mode 100644 index 000000000..4478acada --- /dev/null +++ b/src/utils/configMerge.ts @@ -0,0 +1,293 @@ +/** + * 配置合并工具函数 + * + * 用于公共配置重构后的运行时合并逻辑: + * - computeFinalConfig: 计算最终配置(通用配置 + 自定义配置) + * - extractDifference: 从 live 配置中提取与通用配置不同的部分 + */ + +// ============================================================================ +// 工具函数 +// ============================================================================ + +/** + * 检查值是否为普通对象 + */ +export const isPlainObject = ( + value: unknown, +): value is Record => { + return Object.prototype.toString.call(value) === "[object Object]"; +}; + +/** + * 深拷贝对象 + */ +export const deepClone = (obj: T): T => { + if (obj === null || typeof obj !== "object") return obj; + if (obj instanceof Date) return new Date(obj.getTime()) as T; + if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as T; + if (isPlainObject(obj)) { + const clonedObj = {} as Record; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + clonedObj[key] = deepClone((obj as Record)[key]); + } + } + return clonedObj as T; + } + return obj; +}; + +/** + * 深度相等比较 + */ +export const deepEqual = (a: unknown, b: unknown): boolean => { + if (a === b) return true; + + if (typeof a !== typeof b) return false; + if (a === null || b === null) return a === b; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, index) => deepEqual(item, b[index])); + } + + if (isPlainObject(a) && isPlainObject(b)) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every((key) => deepEqual(a[key], b[key])); + } + + return false; +}; + +// ============================================================================ +// 配置合并函数 +// ============================================================================ + +/** + * 深度合并两个对象(source 覆盖 target) + * + * 合并规则: + * - 嵌套对象:递归合并 + * - 数组:source 完全替换 target(不做元素级合并) + * - 原始值:source 覆盖 target + * - undefined:不覆盖 + */ +export const deepMerge = >( + target: T, + source: T, +): T => { + const result = deepClone(target); + + for (const key of Object.keys(source)) { + const sourceValue = source[key]; + const targetValue = result[key]; + + // undefined 不覆盖 + if (sourceValue === undefined) { + continue; + } + + if (isPlainObject(sourceValue) && isPlainObject(targetValue)) { + // 嵌套对象:递归合并 + result[key as keyof T] = deepMerge( + targetValue as Record, + sourceValue as Record, + ) as T[keyof T]; + } else { + // 其他情况(数组、原始值):source 覆盖 + result[key as keyof T] = deepClone(sourceValue) as T[keyof T]; + } + } + + return result; +}; + +/** + * 计算最终配置 + * + * 通用配置作为 base,自定义配置覆盖(自定义优先) + * + * @param customConfig - 供应商自定义配置(来自 settings_config 字段) + * @param commonConfig - 通用配置片段(来自数据库) + * @param enabled - 是否启用通用配置 + * @returns 合并后的最终配置 + */ +export const computeFinalConfig = ( + customConfig: Record, + commonConfig: Record, + enabled: boolean, +): Record => { + const safeCustom = customConfig ?? {}; + const safeCommon = commonConfig ?? {}; + + if (!enabled || Object.keys(safeCommon).length === 0) { + return deepClone(safeCustom); + } + + // 通用配置作为 base,自定义配置覆盖 + // 这样自定义配置的值会优先 + return deepMerge(safeCommon, safeCustom); +}; + +// ============================================================================ +// 差异提取函数 +// ============================================================================ + +/** + * 差异提取结果 + */ +export interface ExtractDifferenceResult { + /** 自定义配置(与通用配置不同的部分) */ + customConfig: Record; + /** 是否检测到通用配置的键(用于判断是否应启用通用配置) */ + hasCommonKeys: boolean; +} + +/** + * 从 live 配置中提取与通用配置不同的部分作为自定义配置 + * + * 提取规则: + * - 通用配置中不存在的键 → 加入自定义配置 + * - 通用配置中存在但值不同 → 加入自定义配置(用户覆盖) + * - 通用配置中存在且值相同 → 跳过(避免冗余存储) + * + * @param liveConfig - 从本地文件读取的配置 + * @param commonConfig - 通用配置片段 + * @returns { customConfig, hasCommonKeys } + */ +export const extractDifference = ( + liveConfig: Record, + commonConfig: Record, +): ExtractDifferenceResult => { + const customConfig: Record = {}; + let hasCommonKeys = false; + + /** + * 递归提取差异 + */ + const extract = ( + live: Record, + common: Record, + target: Record, + ): void => { + for (const [key, liveValue] of Object.entries(live)) { + const commonValue = common[key]; + + if (commonValue === undefined) { + // Case 1: 通用配置中不存在该键,完整保留到自定义配置 + target[key] = deepClone(liveValue); + } else if (isPlainObject(liveValue) && isPlainObject(commonValue)) { + // Case 2: 嵌套对象,递归处理 + const nested: Record = {}; + extract( + liveValue as Record, + commonValue as Record, + nested, + ); + if (Object.keys(nested).length > 0) { + // 嵌套对象有差异,保留差异部分 + target[key] = nested; + } else { + // 嵌套对象完全相同,标记有通用配置的键 + hasCommonKeys = true; + } + } else if (!deepEqual(liveValue, commonValue)) { + // Case 3: 值不同,保留到自定义配置(用户覆盖) + target[key] = deepClone(liveValue); + } else { + // Case 4: 值完全相同,不保存到自定义配置(避免冗余) + hasCommonKeys = true; + } + } + }; + + extract(liveConfig, commonConfig, customConfig); + + return { customConfig, hasCommonKeys }; +}; + +// ============================================================================ +// JSON 格式便捷函数 +// ============================================================================ + +/** + * 计算最终配置(JSON 字符串版本) + */ +export const computeFinalConfigJson = ( + customConfigJson: string, + commonConfigJson: string, + enabled: boolean, +): { finalConfig: string; error?: string } => { + try { + const customConfig = customConfigJson ? JSON.parse(customConfigJson) : {}; + const commonConfig = commonConfigJson ? JSON.parse(commonConfigJson) : {}; + + if (!isPlainObject(customConfig)) { + return { + finalConfig: customConfigJson, + error: "自定义配置必须是 JSON 对象", + }; + } + + if (!isPlainObject(commonConfig)) { + return { + finalConfig: customConfigJson, + error: "通用配置必须是 JSON 对象", + }; + } + + const final = computeFinalConfig(customConfig, commonConfig, enabled); + return { + finalConfig: JSON.stringify(final, null, 2), + }; + } catch (err) { + return { + finalConfig: customConfigJson, + error: `JSON 解析失败: ${err instanceof Error ? err.message : String(err)}`, + }; + } +}; + +/** + * 从 JSON 字符串中提取差异 + */ +export const extractDifferenceJson = ( + liveConfigJson: string, + commonConfigJson: string, +): { customConfig: string; hasCommonKeys: boolean; error?: string } => { + try { + const liveConfig = liveConfigJson ? JSON.parse(liveConfigJson) : {}; + const commonConfig = commonConfigJson ? JSON.parse(commonConfigJson) : {}; + + if (!isPlainObject(liveConfig)) { + return { + customConfig: liveConfigJson, + hasCommonKeys: false, + error: "Live 配置必须是 JSON 对象", + }; + } + + if (!isPlainObject(commonConfig)) { + return { + customConfig: liveConfigJson, + hasCommonKeys: false, + error: "通用配置必须是 JSON 对象", + }; + } + + const result = extractDifference(liveConfig, commonConfig); + return { + customConfig: JSON.stringify(result.customConfig, null, 2), + hasCommonKeys: result.hasCommonKeys, + }; + } catch (err) { + return { + customConfig: liveConfigJson, + hasCommonKeys: false, + error: `JSON 解析失败: ${err instanceof Error ? err.message : String(err)}`, + }; + } +}; diff --git a/src/utils/tomlConfigMerge.ts b/src/utils/tomlConfigMerge.ts new file mode 100644 index 000000000..92a14c10c --- /dev/null +++ b/src/utils/tomlConfigMerge.ts @@ -0,0 +1,409 @@ +/** + * TOML 配置合并工具函数 + * + * 用于 Codex 的 TOML 格式配置合并和差异提取。 + * TOML 配置需要先解析为对象,然后使用通用的合并/提取算法。 + */ + +import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; +import { normalizeTomlText } from "@/utils/textNormalization"; +import { + computeFinalConfig, + extractDifference, + isPlainObject, +} from "./configMerge"; + +// ============================================================================ +// TOML 解析/序列化工具 +// ============================================================================ + +/** + * 安全解析 TOML 字符串为对象 + */ +export const safeParseToml = ( + tomlString: string, +): { config: Record | null; error: string | null } => { + try { + if (!tomlString.trim()) { + return { config: {}, error: null }; + } + const normalized = normalizeTomlText(tomlString); + const parsed = parseToml(normalized); + if (!isPlainObject(parsed)) { + return { config: null, error: "TOML 解析结果不是对象" }; + } + return { config: parsed as Record, error: null }; + } catch (e) { + return { + config: null, + error: e instanceof Error ? e.message : "TOML 解析失败", + }; + } +}; + +/** + * 将对象序列化为 TOML 字符串 + * 并移除冗余的空父级 section 头(如 [model_providers] 后面紧跟 [model_providers.packycode]) + */ +export const safeStringifyToml = ( + config: Record, +): { toml: string; error: string | null } => { + try { + if (Object.keys(config).length === 0) { + return { toml: "", error: null }; + } + let toml = stringifyToml(config); + + // 移除冗余的空父级 section 头 + // 例如:[model_providers]\n[model_providers.packycode] -> [model_providers.packycode] + toml = removeEmptyParentSections(toml); + + return { toml, error: null }; + } catch (e) { + return { + toml: "", + error: e instanceof Error ? e.message : "TOML 序列化失败", + }; + } +}; + +/** + * 移除冗余的空父级 section 头 + * 当一个 section 头后面紧跟的是它的子 section(没有任何键值对),则移除这个空的父级 section + */ +function removeEmptyParentSections(toml: string): string { + const lines = toml.split("\n"); + const result: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // 检查是否是 section 头 + const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); + if (sectionMatch) { + const currentSection = sectionMatch[1]; + + // 查找下一个非空行 + let nextIdx = i + 1; + while (nextIdx < lines.length && !lines[nextIdx].trim()) { + nextIdx++; + } + + // 如果下一个非空行也是一个 section 头,检查是否是子 section + if (nextIdx < lines.length) { + const nextLine = lines[nextIdx].trim(); + const nextSectionMatch = nextLine.match(/^\[([^\]]+)\]$/); + if (nextSectionMatch) { + const nextSection = nextSectionMatch[1]; + // 如果下一个 section 是当前 section 的子 section,跳过当前空的父 section + if (nextSection.startsWith(currentSection + ".")) { + // 跳过当前行和之间的空行 + continue; + } + } + } + } + + result.push(line); + } + + return result.join("\n"); +} + +// ============================================================================ +// TOML 配置合并函数 +// ============================================================================ + +/** + * 计算最终 TOML 配置 + * + * @param customToml - 自定义 TOML 配置字符串 + * @param commonToml - 通用 TOML 配置字符串 + * @param enabled - 是否启用通用配置 + * @returns 合并后的 TOML 字符串 + */ +export const computeFinalTomlConfig = ( + customToml: string, + commonToml: string, + enabled: boolean, +): { finalConfig: string; error?: string } => { + // 如果未启用或通用配置为空,直接返回自定义配置 + if (!enabled || !commonToml.trim()) { + return { finalConfig: customToml }; + } + + // 解析自定义配置 + const customResult = safeParseToml(customToml); + if (customResult.error) { + return { + finalConfig: customToml, + error: `自定义配置解析失败: ${customResult.error}`, + }; + } + + // 解析通用配置 + const commonResult = safeParseToml(commonToml); + if (commonResult.error) { + return { + finalConfig: customToml, + error: `通用配置解析失败: ${commonResult.error}`, + }; + } + + // 使用通用合并函数 + const merged = computeFinalConfig( + customResult.config!, + commonResult.config!, + true, // enabled 已在上面检查 + ); + + // 序列化回 TOML + const stringifyResult = safeStringifyToml(merged); + if (stringifyResult.error) { + return { + finalConfig: customToml, + error: `TOML 序列化失败: ${stringifyResult.error}`, + }; + } + + return { finalConfig: stringifyResult.toml }; +}; + +// ============================================================================ +// TOML 差异提取函数 +// ============================================================================ + +/** + * TOML 差异提取结果 + */ +export interface ExtractTomlDifferenceResult { + /** 自定义 TOML 配置字符串(与通用配置不同的部分) */ + customToml: string; + /** 是否检测到通用配置的键 */ + hasCommonKeys: boolean; + /** 错误信息 */ + error?: string; +} + +/** + * 从 live TOML 配置中提取与通用配置不同的部分 + * + * @param liveToml - 从本地文件读取的 TOML 字符串 + * @param commonToml - 通用 TOML 配置字符串 + * @returns { customToml, hasCommonKeys, error } + */ +export const extractTomlDifference = ( + liveToml: string, + commonToml: string, +): ExtractTomlDifferenceResult => { + // 如果通用配置为空,live 配置就是自定义配置 + if (!commonToml.trim()) { + return { customToml: liveToml, hasCommonKeys: false }; + } + + // 解析 live 配置 + const liveResult = safeParseToml(liveToml); + if (liveResult.error) { + return { + customToml: liveToml, + hasCommonKeys: false, + error: `Live 配置解析失败: ${liveResult.error}`, + }; + } + + // 解析通用配置 + const commonResult = safeParseToml(commonToml); + if (commonResult.error) { + return { + customToml: liveToml, + hasCommonKeys: false, + error: `通用配置解析失败: ${commonResult.error}`, + }; + } + + // 使用通用差异提取函数 + const diffResult = extractDifference( + liveResult.config!, + commonResult.config!, + ); + + // 序列化回 TOML + const stringifyResult = safeStringifyToml(diffResult.customConfig); + if (stringifyResult.error) { + return { + customToml: liveToml, + hasCommonKeys: false, + error: `TOML 序列化失败: ${stringifyResult.error}`, + }; + } + + return { + customToml: stringifyResult.toml, + hasCommonKeys: diffResult.hasCommonKeys, + }; +}; + +// ============================================================================ +// TOML Section 级别处理(保留原有格式和注释的场景) +// ============================================================================ + +/** + * TOML 解析结构(用于保留格式的场景) + */ +export interface TomlParsedStructure { + topLevel: string[]; + sections: Map; + sectionOrder: string[]; +} + +/** + * 解析 TOML 文本为段落结构(保留原始行格式) + */ +export const parseTomlStructure = (tomlText: string): TomlParsedStructure => { + const lines = tomlText.split("\n"); + const topLevel: string[] = []; + const sections = new Map(); + const sectionOrder: string[] = []; + + let currentSection: string | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + + // 检查是否是 section 头 + const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); + if (sectionMatch) { + currentSection = sectionMatch[1]; + if (!sections.has(currentSection)) { + sections.set(currentSection, []); + sectionOrder.push(currentSection); + } + continue; + } + + if (currentSection === null) { + topLevel.push(line); + } else { + const sectionLines = sections.get(currentSection)!; + sectionLines.push(line); + } + } + + return { topLevel, sections, sectionOrder }; +}; + +/** + * 从行中提取键名 + */ +export const extractKeyFromLine = (line: string): string | null => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + return null; + } + const match = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=/); + return match ? match[1] : null; +}; + +/** + * 重建 TOML 文本(从结构) + */ +export const rebuildTomlFromStructure = ( + structure: TomlParsedStructure, +): string => { + const resultLines: string[] = []; + + // 添加顶级内容 + let lastWasEmpty = true; + for (const line of structure.topLevel) { + const isEmpty = !line.trim(); + if (isEmpty && lastWasEmpty) { + continue; + } + resultLines.push(line); + lastWasEmpty = isEmpty; + } + + // 添加 sections + for (const sectionName of structure.sectionOrder) { + const sectionLines = structure.sections.get(sectionName)!; + + if (resultLines.length > 0 && resultLines[resultLines.length - 1].trim()) { + resultLines.push(""); + } + + resultLines.push(`[${sectionName}]`); + + lastWasEmpty = false; + for (const line of sectionLines) { + const isEmpty = !line.trim(); + if (isEmpty && lastWasEmpty) { + continue; + } + resultLines.push(line); + lastWasEmpty = isEmpty; + } + } + + // 清理末尾空行 + while ( + resultLines.length > 0 && + !resultLines[resultLines.length - 1].trim() + ) { + resultLines.pop(); + } + + return resultLines.length > 0 ? resultLines.join("\n") + "\n" : ""; +}; + +/** + * 检查 TOML 配置是否包含通用配置的内容 + * 使用对象级别比较,而非字符串匹配 + */ +export const hasTomlCommonConfig = ( + tomlString: string, + commonTomlString: string, +): boolean => { + if (!commonTomlString.trim()) return false; + + const liveResult = safeParseToml(tomlString); + const commonResult = safeParseToml(commonTomlString); + + if (liveResult.error || commonResult.error) { + return false; + } + + // 检查 common 中的所有键是否存在于 live 中且值相等 + const checkSubset = ( + live: Record, + common: Record, + ): boolean => { + for (const [key, commonValue] of Object.entries(common)) { + const liveValue = live[key]; + + if (liveValue === undefined) { + return false; + } + + if (isPlainObject(commonValue) && isPlainObject(liveValue)) { + if ( + !checkSubset( + liveValue as Record, + commonValue as Record, + ) + ) { + return false; + } + } else if (Array.isArray(commonValue) && Array.isArray(liveValue)) { + if (JSON.stringify(commonValue) !== JSON.stringify(liveValue)) { + return false; + } + } else if (liveValue !== commonValue) { + return false; + } + } + return true; + }; + + return checkSubset(liveResult.config!, commonResult.config!); +};