refactor(config): extract common config merge to shared function

Address code audit findings #1, #2, #6, #7, #11:

- Extract merge logic to merge_config_for_live() in config_merge.rs
- Add is_common_config_enabled() helper function
- Add MergeResult struct with config and optional warning
- Update live.rs and proxy.rs to use the shared function
- Add logging for JSON parse failures instead of silent fallback
- Convert Chinese error messages to error codes (CODEX_CONFIG_*)
- Support both flat and wrapped formats for Gemini common config

This eliminates code duplication between live.rs and proxy.rs,
making future maintenance easier and preventing behavioral drift.
This commit is contained in:
YoVinchen
2026-01-26 15:25:13 +08:00
parent e54e4d47ae
commit 73fea48049
3 changed files with 240 additions and 167 deletions
+189
View File
@@ -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<String>,
}
/// 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
// ============================================================================
+31 -93
View File
@@ -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();
+20 -74
View File
@@ -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)
}