diff --git a/src-tauri/src/config_merge.rs b/src-tauri/src/config_merge.rs index 7fa5d3656..d5e601b6a 100644 --- a/src-tauri/src/config_merge.rs +++ b/src-tauri/src/config_merge.rs @@ -529,6 +529,195 @@ pub fn extract_toml_difference_str( } } +// ============================================================================ +// Live Config Merge for Provider Sync +// ============================================================================ + +use crate::app_config::AppType; +use crate::provider::{Provider, ProviderMeta}; + +/// Result of merging common config with provider's custom config. +#[derive(Debug)] +pub struct MergeResult { + /// The final merged configuration + pub config: JsonValue, + /// Warning message if any (e.g., parse errors that were recovered from) + pub warning: Option, +} + +/// Check if common config is enabled for a provider and app type. +/// +/// Priority: +/// 1. `meta.common_config_enabled_by_app.{app_type}` (per-app setting) +/// 2. `meta.common_config_enabled` (global setting) +/// 3. `false` (default) +pub fn is_common_config_enabled(meta: Option<&ProviderMeta>, app_type: &AppType) -> bool { + meta.and_then(|m| { + m.common_config_enabled_by_app + .as_ref() + .and_then(|by_app| match app_type { + AppType::Claude => by_app.claude, + AppType::Codex => by_app.codex, + AppType::Gemini => by_app.gemini, + AppType::OpenCode => None, // OpenCode doesn't support common config + }) + .or(m.common_config_enabled) + }) + .unwrap_or(false) +} + +/// Merge common config with provider's custom config for live file writing. +/// +/// This is the single source of truth for common config merging logic. +/// Used by both `live.rs` and `proxy.rs`. +/// +/// # Arguments +/// * `app_type` - The application type (Claude, Codex, Gemini, OpenCode) +/// * `provider` - The provider whose config is being merged +/// * `common_snippet` - The common config snippet from database (may be empty) +/// +/// # Returns +/// `MergeResult` containing the final config and optional warning +pub fn merge_config_for_live( + app_type: &AppType, + provider: &Provider, + common_snippet: Option<&str>, +) -> MergeResult { + // Check if common config is enabled + let enabled = is_common_config_enabled(provider.meta.as_ref(), app_type); + + // If not enabled or snippet is empty, return original config + let snippet = match common_snippet { + Some(s) if enabled && !s.trim().is_empty() => s, + _ => { + return MergeResult { + config: provider.settings_config.clone(), + warning: None, + } + } + }; + + // Perform merge based on app type + match app_type { + AppType::Claude => merge_claude_config(&provider.settings_config, snippet), + AppType::Codex => merge_codex_config(&provider.settings_config, snippet), + AppType::Gemini => merge_gemini_config(&provider.settings_config, snippet), + AppType::OpenCode => { + // OpenCode doesn't support common config merge + MergeResult { + config: provider.settings_config.clone(), + warning: None, + } + } + } +} + +/// Merge Claude config (JSON format). +fn merge_claude_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult { + let common_value: JsonValue = match serde_json::from_str(common_snippet) { + Ok(v) => v, + Err(e) => { + log::warn!( + "Claude common config snippet parse error, skipping merge: {e}" + ); + return MergeResult { + config: custom_config.clone(), + warning: Some(format!("COMMON_CONFIG_PARSE_ERROR: {e}")), + }; + } + }; + + MergeResult { + config: compute_final_json_config(custom_config, &common_value, true), + warning: None, + } +} + +/// Merge Codex config (TOML for config field, JSON for auth field). +fn merge_codex_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult { + let mut merged_config = custom_config.clone(); + let mut warning = None; + + if let Some(obj) = merged_config.as_object_mut() { + if let Some(config_str) = obj.get("config").and_then(|v| v.as_str()) { + let (merged_toml, error) = + compute_final_toml_config_str(config_str, common_snippet, true); + if let Some(e) = error { + log::warn!("Codex common config merge warning: {e}"); + warning = Some(format!("CODEX_TOML_MERGE_WARNING: {e}")); + } + obj.insert("config".to_string(), JsonValue::String(merged_toml)); + } + } + + MergeResult { + config: merged_config, + warning, + } +} + +/// Merge Gemini config (JSON format for env field). +/// +/// Gemini common config can be stored in two formats: +/// - Wrapped: `{"env": {"KEY": "VALUE", ...}}` (matches provider settings_config structure) +/// - Flat: `{"KEY": "VALUE", ...}` (simpler format used by frontend) +/// +/// This function supports both formats for backward compatibility. +fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult { + let common_value: JsonValue = match serde_json::from_str(common_snippet) { + Ok(v) => v, + Err(e) => { + log::warn!( + "Gemini common config snippet parse error, skipping merge: {e}" + ); + return MergeResult { + config: custom_config.clone(), + warning: Some(format!("COMMON_CONFIG_PARSE_ERROR: {e}")), + }; + } + }; + + let mut merged_config = custom_config.clone(); + + // Get the common env object (support both wrapped and flat formats) + let common_env = match common_value.as_object() { + Some(obj) => { + // Check if it's wrapped format {"env": {...}} + if let Some(env_value) = obj.get("env").and_then(|v| v.as_object()) { + env_value.clone() + } else { + // Flat format {"KEY": "VALUE", ...} + obj.clone() + } + } + None => { + return MergeResult { + config: custom_config.clone(), + warning: Some("COMMON_CONFIG_NOT_OBJECT".to_string()), + }; + } + }; + + // Merge only the env field + if let Some(merged_obj) = merged_config.as_object_mut() { + if let Some(merged_env) = merged_obj.get_mut("env") { + if let Some(merged_env_obj) = merged_env.as_object_mut() { + // Common env as base, custom env overrides + let mut final_env = common_env; + for (k, v) in merged_env_obj.iter() { + final_env.insert(k.clone(), v.clone()); + } + *merged_env = JsonValue::Object(final_env); + } + } + } + + MergeResult { + config: merged_config, + warning: None, + } +} + // ============================================================================ // Unit Tests // ============================================================================ diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 38db1019b..38f7aa241 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -17,7 +17,7 @@ use serde_json::{json, Value}; use crate::app_config::AppType; use crate::codex_config::{get_codex_auth_path, get_codex_config_path}; use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file}; -use crate::config_merge::{compute_final_json_config, compute_final_toml_config_str}; +use crate::config_merge::merge_config_for_live; use crate::error::AppError; use crate::provider::Provider; use crate::services::mcp::McpService; @@ -124,97 +124,31 @@ pub(crate) fn write_live_snapshot_with_merge( // Get common config snippet from database let common_config_snippet = state.db.get_config_snippet(app_type.as_str())?; - // Check if common config is enabled for this app - let common_config_enabled = provider - .meta - .as_ref() - .and_then(|m| { - m.common_config_enabled_by_app - .as_ref() - .and_then(|by_app| match app_type { - AppType::Claude => by_app.claude, - AppType::Codex => by_app.codex, - AppType::Gemini => by_app.gemini, - AppType::OpenCode => None, // OpenCode doesn't support common config - }) - .or(m.common_config_enabled) - }) - .unwrap_or(false); + // Use shared merge function (single source of truth) + let merge_result = merge_config_for_live(app_type, provider, common_config_snippet.as_deref()); - // If common config is not enabled or snippet is empty, write raw config - if !common_config_enabled - || common_config_snippet - .as_ref() - .is_none_or(|s| s.trim().is_empty()) - { - return write_live_snapshot(app_type, provider); + // Log warning if any + if let Some(warning) = &merge_result.warning { + log::warn!( + "Common config merge warning for {:?} provider '{}': {}", + app_type, + provider.id, + warning + ); } - let snippet = common_config_snippet.unwrap(); - - // Perform runtime merge based on app type - let final_config = match app_type { - AppType::Claude => { - // Claude uses JSON format - let common_value: Value = serde_json::from_str(&snippet).unwrap_or(json!({})); - compute_final_json_config(&provider.settings_config, &common_value, true) - } - AppType::Codex => { - // Codex uses TOML for config field, JSON for auth field - // Merge only the config field (TOML), keep auth field unchanged - let mut merged_config = provider.settings_config.clone(); - if let Some(obj) = merged_config.as_object_mut() { - if let Some(config_str) = obj.get("config").and_then(|v| v.as_str()) { - let (merged_toml, error) = - compute_final_toml_config_str(config_str, &snippet, true); - if let Some(e) = error { - log::warn!("Codex common config merge warning: {e}"); - } - obj.insert("config".to_string(), json!(merged_toml)); - } - } - merged_config - } - AppType::Gemini => { - // Gemini uses JSON format for env field - let common_value: Value = serde_json::from_str(&snippet).unwrap_or(json!({})); - let mut merged_config = provider.settings_config.clone(); - - // Merge only the env field - if let (Some(merged_obj), Some(common_obj)) = - (merged_config.as_object_mut(), common_value.as_object()) - { - if let Some(merged_env) = merged_obj.get_mut("env") { - if let (Some(merged_env_obj), Some(common_env)) = ( - merged_env.as_object_mut(), - common_obj.get("env").and_then(|v| v.as_object()), - ) { - // Common env as base, custom env overrides - let mut final_env = common_env.clone(); - for (k, v) in merged_env_obj.iter() { - final_env.insert(k.clone(), v.clone()); - } - *merged_env = json!(final_env); - } - } - } - merged_config - } - AppType::OpenCode => { - // OpenCode doesn't support common config merge - provider.settings_config.clone() - } - }; - - log::debug!( - "Writing live config with common config merge for {:?} provider '{}'", - app_type, - provider.id - ); + // Check if merge actually happened (config changed) + if merge_result.config != provider.settings_config { + log::debug!( + "Writing live config with common config merge for {:?} provider '{}'", + app_type, + provider.id + ); + } // Write the merged config to live file let merged_provider = Provider { - settings_config: final_config, + settings_config: merge_result.config, ..provider.clone() }; write_live_snapshot_internal(app_type, &merged_provider, &merged_provider.settings_config) @@ -232,14 +166,18 @@ fn write_live_snapshot_internal( write_json_file(&path, config_to_write)?; } AppType::Codex => { - let obj = config_to_write - .as_object() - .ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?; - let auth = obj - .get("auth") - .ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?; + let obj = config_to_write.as_object().ok_or_else(|| { + AppError::Config( + "CODEX_CONFIG_NOT_OBJECT: settings_config must be a JSON object".to_string(), + ) + })?; + let auth = obj.get("auth").ok_or_else(|| { + AppError::Config( + "CODEX_CONFIG_MISSING_AUTH: settings_config missing 'auth' field".to_string(), + ) + })?; let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| { - AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string()) + AppError::Config("CODEX_CONFIG_MISSING_CONFIG: settings_config missing 'config' field or not a string".to_string()) })?; let auth_path = get_codex_auth_path(); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 5cdc1b0d4..864f10dea 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -4,7 +4,7 @@ use crate::app_config::AppType; use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; -use crate::config_merge::{compute_final_json_config, compute_final_toml_config_str}; +use crate::config_merge::merge_config_for_live; use crate::database::Database; use crate::provider::Provider; use crate::proxy::server::ProxyServer; @@ -1233,83 +1233,29 @@ impl ProxyService { return Ok(false); }; - // Check if common config should be merged + // Use shared merge function (single source of truth) let common_config_snippet = self.db.get_config_snippet(app_type.as_str()).ok().flatten(); + let merge_result = + merge_config_for_live(app_type, provider, common_config_snippet.as_deref()); - let common_config_enabled = provider - .meta - .as_ref() - .and_then(|m| { - m.common_config_enabled_by_app - .as_ref() - .and_then(|by_app| match app_type { - AppType::Claude => by_app.claude, - AppType::Codex => by_app.codex, - AppType::Gemini => by_app.gemini, - AppType::OpenCode => None, - }) - .or(m.common_config_enabled) - }) - .unwrap_or(false); - - // If common config is enabled and snippet exists, merge before writing - if common_config_enabled - && common_config_snippet - .as_ref() - .is_some_and(|s| !s.trim().is_empty()) - { - let snippet = common_config_snippet.unwrap(); - let final_config = match app_type { - AppType::Claude => { - let common_value: Value = serde_json::from_str(&snippet).unwrap_or(json!({})); - compute_final_json_config(&provider.settings_config, &common_value, true) - } - AppType::Codex => { - let mut merged_config = provider.settings_config.clone(); - if let Some(obj) = merged_config.as_object_mut() { - if let Some(config_str) = obj.get("config").and_then(|v| v.as_str()) { - let (merged_toml, _) = - compute_final_toml_config_str(config_str, &snippet, true); - obj.insert("config".to_string(), json!(merged_toml)); - } - } - merged_config - } - AppType::Gemini => { - let common_value: Value = serde_json::from_str(&snippet).unwrap_or(json!({})); - let mut merged_config = provider.settings_config.clone(); - if let (Some(merged_obj), Some(common_obj)) = - (merged_config.as_object_mut(), common_value.as_object()) - { - if let Some(merged_env) = merged_obj.get_mut("env") { - if let (Some(merged_env_obj), Some(common_env)) = ( - merged_env.as_object_mut(), - common_obj.get("env").and_then(|v| v.as_object()), - ) { - let mut final_env = common_env.clone(); - for (k, v) in merged_env_obj.iter() { - final_env.insert(k.clone(), v.clone()); - } - *merged_env = json!(final_env); - } - } - } - merged_config - } - AppType::OpenCode => provider.settings_config.clone(), - }; - - let merged_provider = Provider { - settings_config: final_config, - ..provider.clone() - }; - write_live_snapshot(app_type, &merged_provider) - .map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?; - } else { - write_live_snapshot(app_type, provider) - .map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?; + // Log warning if any + if let Some(warning) = &merge_result.warning { + log::warn!( + "Common config merge warning for {:?} provider '{}': {}", + app_type, + provider.id, + warning + ); } + // Write merged config to live file + let merged_provider = Provider { + settings_config: merge_result.config, + ..provider.clone() + }; + write_live_snapshot(app_type, &merged_provider) + .map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?; + Ok(true) }