diff --git a/src-tauri/src/cli/commands/edit_json.rs b/src-tauri/src/cli/commands/edit_json.rs new file mode 100644 index 00000000..e1cdfb9a --- /dev/null +++ b/src-tauri/src/cli/commands/edit_json.rs @@ -0,0 +1,323 @@ +use clap::Subcommand; +use serde_json::Value; + +use crate::app_config::AppType; +use crate::error::AppError; +use crate::provider::Provider; +use crate::services::provider::ProviderService; + +#[derive(Subcommand)] +pub enum EditJsonCommand { + /// Edit a provider's settings_config JSON in an external editor + Provider { + /// Provider ID + id: String, + + /// Application type + #[arg(long, value_enum)] + app_type: AppType, + + /// Replace settings_config entirely (skip merge with existing keys) + #[arg(long, default_value_t = false)] + force: bool, + }, +} + +pub fn execute(cmd: EditJsonCommand) -> Result<(), AppError> { + match cmd { + EditJsonCommand::Provider { + id, + app_type, + force, + } => edit_provider(&app_type, &id, force), + } +} + +/// Open the provider's settings_config in an external editor, validate the result, +/// and persist — merging with existing keys by default, or fully replacing when `force` is set. +fn edit_provider(app_type: &AppType, id: &str, force: bool) -> Result<(), AppError> { + let state = crate::store::AppState::try_new()?; + + let provider = state + .db + .get_provider_by_id(id, app_type.as_str())? + .ok_or_else(|| { + AppError::InvalidInput(format!( + "provider '{}' not found for app '{}'", + id, + app_type.as_str() + )) + })?; + + let initial = serde_json::to_string_pretty(&provider.settings_config) + .map_err(|e| AppError::Message(format!("failed to serialize settings_config: {e}")))?; + + let edited = crate::cli::editor::open_external_editor(&initial)?; + + if edited.trim() == initial.trim() { + println!("未修改,已取消"); + return Ok(()); + } + + let new_value = validate_edited_json(&edited, &provider, app_type)?; + + state + .db + .update_provider_settings_config(app_type.as_str(), id, &new_value, force)?; + + use crate::cli::ui::success; + println!( + "{}", + success(&format!( + "✓ 已更新 provider '{}' ({}) 的 settingsConfig", + id, + app_type.as_str() + )) + ); + Ok(()) +} + +/// Validate edited JSON: syntax → must be Object → business rules. +fn validate_edited_json( + edited: &str, + provider: &Provider, + app_type: &AppType, +) -> Result { + let value: Value = serde_json::from_str(edited).map_err(|e| { + AppError::Message(format!("JSON 解析失败: {e}")) + })?; + + if !value.is_object() { + return Err(AppError::Message( + "settingsConfig 必须为 JSON Object".to_string(), + )); + } + + if matches!(app_type, AppType::Codex) && !ProviderService::is_codex_official_provider(provider) { + let config_text = value + .get("config") + .and_then(Value::as_str) + .unwrap_or(""); + if !ProviderService::codex_config_has_base_url(config_text) { + return Err(AppError::Message( + "Codex provider 必须配置非空的 base_url".to_string(), + )); + } + } + + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + use crate::provider::{Provider, ProviderMeta}; + use serde_json::json; + + fn make_provider(id: &str, settings_config: Value) -> Provider { + let mut p = Provider::with_id(id.to_string(), "Test Provider".to_string(), settings_config, None); + p.meta = Some(ProviderMeta::default()); + p + } + + fn seed_provider(db: &Database, id: &str, app_type: &str, cfg: Value) { + let p = make_provider(id, cfg); + db.save_provider(app_type, &p).expect("seed provider"); + } + + #[test] + fn save_provider_update_merges_custom_keys() { + let db = Database::memory().expect("memory db"); + // Seed with a custom key + seed_provider( + &db, + "test-id", + "claude", + json!({"env": {"ANTHROPIC_BASE_URL": "https://old.example.com"}, "customKey": "my-value"}), + ); + + // Simulate update that only touches canonical keys + let mut updated = make_provider("test-id", json!({"env": {"ANTHROPIC_BASE_URL": "https://new.example.com"}})); + updated.meta = None; // so save_provider preserves old meta + db.save_provider("claude", &updated).expect("save"); + + let after = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + // canonical key updated + assert_eq!(after.settings_config["env"]["ANTHROPIC_BASE_URL"], "https://new.example.com"); + // custom key preserved by merge + assert_eq!(after.settings_config["customKey"], "my-value"); + } + + #[test] + fn update_provider_settings_config_merges_by_default() { + let db = Database::memory().expect("memory db"); + seed_provider( + &db, + "test-id", + "claude", + json!({"env": {"BASE_URL": "old"}, "custom": "keep-me"}), + ); + + db.update_provider_settings_config( + "claude", + "test-id", + &json!({"env": {"BASE_URL": "new"}}), + false, // merge mode + ) + .expect("update"); + + let after = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + assert_eq!(after.settings_config["env"]["BASE_URL"], "new"); + assert_eq!(after.settings_config["custom"], "keep-me"); + } + + #[test] + fn update_provider_settings_config_force_replaces_entirely() { + let db = Database::memory().expect("memory db"); + seed_provider( + &db, + "test-id", + "claude", + json!({"env": {"BASE_URL": "old"}, "custom": "should-be-gone"}), + ); + + db.update_provider_settings_config( + "claude", + "test-id", + &json!({"env": {"BASE_URL": "new"}}), + true, // force replace + ) + .expect("update"); + + let after = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + assert_eq!(after.settings_config["env"]["BASE_URL"], "new"); + assert!(after.settings_config.get("custom").is_none(), "custom key should be removed by force replace"); + } + + #[test] + fn json_syntax_error() { + let db = Database::memory().expect("memory db"); + seed_provider(&db, "test-id", "claude", json!({"key": "value"})); + + let provider = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + + let result = validate_edited_json("{broken", &provider, &AppType::Claude); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("JSON 解析失败")); + } + + #[test] + fn non_object_rejected() { + let db = Database::memory().expect("memory db"); + seed_provider(&db, "test-id", "claude", json!({"key": "value"})); + + let provider = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + + for invalid in &["[]", "\"string\"", "42", "null"] { + let result = validate_edited_json(invalid, &provider, &AppType::Claude); + assert!( + result.is_err(), + "expected error for input: {}", + invalid + ); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("JSON Object"), + "unexpected error for '{}': {err}", + invalid + ); + } + } + + #[test] + fn codex_official_skips_base_url_check() { + let db = Database::memory().expect("memory db"); + let mut provider = make_provider("test-id", json!({})); + provider.meta.as_mut().unwrap().codex_official = Some(true); + db.save_provider("codex", &provider).expect("seed"); + + let provider = db + .get_provider_by_id("test-id", "codex") + .expect("query") + .expect("exists"); + + let result = validate_edited_json("{}", &provider, &AppType::Codex); + assert!(result.is_ok(), "official codex should skip base_url check"); + } + + #[test] + fn codex_non_official_missing_base_url_fails() { + let db = Database::memory().expect("memory db"); + let provider = make_provider("test-id", json!({ + "config": "[model_provider]\nprovider = \"custom\"\n" + })); + db.save_provider("codex", &provider).expect("seed"); + + let provider = db + .get_provider_by_id("test-id", "codex") + .expect("query") + .expect("exists"); + + let edited = json!({"config": "[model_provider]\nprovider = \"custom\"\n"}).to_string(); + let result = validate_edited_json(&edited, &provider, &AppType::Codex); + assert!(result.is_err()); + + let err = result.unwrap_err().to_string(); + assert!(err.contains("base_url")); + } + + #[test] + fn update_with_identical_content_is_noop() { + let db = Database::memory().expect("memory db"); + let original = json!({"key": "value", "custom": "preserve-me"}); + seed_provider(&db, "test-id", "claude", original.clone()); + + // Merge the same JSON — no actual change should occur + db.update_provider_settings_config( + "claude", + "test-id", + &json!({"key": "value"}), + false, + ) + .expect("update should succeed"); + + let after = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + assert_eq!(after.settings_config, original, "identical merge must not mutate data"); + } + + #[test] + fn empty_object_is_valid() { + let db = Database::memory().expect("memory db"); + seed_provider(&db, "test-id", "claude", json!({"old": true})); + + let provider = db + .get_provider_by_id("test-id", "claude") + .expect("query") + .expect("exists"); + + let new_value = + validate_edited_json("{}", &provider, &AppType::Claude).expect("{} is valid"); + assert!(new_value.is_object()); + assert!(new_value.as_object().unwrap().is_empty()); + } +} diff --git a/src-tauri/src/cli/commands/mod.rs b/src-tauri/src/cli/commands/mod.rs index a7fb1549..29530051 100644 --- a/src-tauri/src/cli/commands/mod.rs +++ b/src-tauri/src/cli/commands/mod.rs @@ -1,4 +1,5 @@ pub mod completions; +pub mod edit_json; pub mod config; mod config_common; pub mod config_webdav; diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 12cf8b1e..b344d0b5 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -78,6 +78,10 @@ pub enum Commands { /// Generate, install, inspect, or uninstall shell completions Completions(commands::completions::CompletionsCommand), + + /// Edit a provider's settings_config JSON in an external editor + #[command(subcommand)] + EditJson(commands::edit_json::EditJsonCommand), } /// Generate shell completions diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index c819a661..961c3425 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -6,8 +6,10 @@ use crate::database::dao::providers_seed::{is_official_seed_id, OFFICIAL_SEEDS}; use crate::database::{lock_conn, Database}; use crate::error::AppError; use crate::provider::{Provider, ProviderMeta}; +use crate::services::provider::json_deep_merge; use indexmap::IndexMap; use rusqlite::params; +use serde_json::Value; use std::collections::{HashMap, HashSet}; impl Database { @@ -209,6 +211,15 @@ impl Database { Ok(false) } + /// Deep-merge `incoming` into the existing settings_config JSON, + /// preserving custom keys that are not present in the incoming value. + fn merge_settings_config(existing_str: &str, incoming: &Value) -> Value { + let existing: Value = serde_json::from_str(existing_str).unwrap_or(Value::Null); + let mut merged = existing; + json_deep_merge(&mut merged, incoming); + merged + } + fn next_sort_index_for_app(&self, app_type: &str) -> Result { let conn = lock_conn!(self.conn); let max: Option = conn @@ -279,18 +290,27 @@ impl Database { let mut meta_clone = provider.meta.clone().unwrap_or_default(); let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); - // 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue) - let existing: Option<(bool, bool)> = tx + // Fetch existing row in one query: is_current, in_failover_queue, and settings_config + let existing: Option<(bool, bool, String)> = tx .query_row( - "SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2", + "SELECT is_current, in_failover_queue, settings_config FROM providers WHERE id = ?1 AND app_type = ?2", params![provider.id, app_type], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .ok(); let is_update = existing.is_some(); - let (is_current, in_failover_queue) = - existing.unwrap_or((false, provider.in_failover_queue)); + let (is_current, in_failover_queue) = existing + .as_ref() + .map(|(c, q, _)| (*c, *q)) + .unwrap_or((false, provider.in_failover_queue)); + + // Merge settings_config: preserve custom keys from existing DB row + let final_settings_config = if let Some((_, _, ref existing_cfg_str)) = existing { + Self::merge_settings_config(existing_cfg_str, &provider.settings_config) + } else { + provider.settings_config.clone() + }; if is_update { // 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE @@ -311,7 +331,7 @@ impl Database { WHERE id = ?13 AND app_type = ?14", params![ provider.name, - serde_json::to_string(&provider.settings_config).map_err(|e| { + serde_json::to_string(&final_settings_config).map_err(|e| { AppError::Database(format!("Failed to serialize settings_config: {e}")) })?, provider.website_url, @@ -342,7 +362,7 @@ impl Database { provider.id, app_type, provider.name, - serde_json::to_string(&provider.settings_config) + serde_json::to_string(&final_settings_config) .map_err(|e| AppError::Database(format!("Failed to serialize settings_config: {e}")))?, provider.website_url, provider.category, @@ -410,18 +430,35 @@ impl Database { Ok(()) } - /// 更新供应商的 settings_config(仅更新配置,不改变其他字段) + /// Update provider's settings_config without touching other fields. + /// Default merges into the existing value; `force=true` replaces entirely. pub fn update_provider_settings_config( &self, app_type: &str, provider_id: &str, settings_config: &serde_json::Value, + force: bool, ) -> Result<(), AppError> { let conn = lock_conn!(self.conn); + let merged = if force { + settings_config.clone() + } else { + let existing_str: Option = conn + .query_row( + "SELECT settings_config FROM providers WHERE id = ?1 AND app_type = ?2", + params![provider_id, app_type], + |row| row.get(0), + ) + .ok(); + match existing_str { + Some(ref s) => Self::merge_settings_config(s, settings_config), + None => settings_config.clone(), + } + }; conn.execute( "UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3", params![ - serde_json::to_string(settings_config).map_err(|e| AppError::Database(format!( + serde_json::to_string(&merged).map_err(|e| AppError::Database(format!( "Failed to serialize settings_config: {e}" )))?, provider_id, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 978df67c..08c49226 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -43,6 +43,7 @@ fn run(cli: Cli) -> Result<(), AppError> { Some(Commands::Env(cmd)) => cc_switch_lib::cli::commands::env::execute(cmd, cli.app), Some(Commands::Update(cmd)) => cc_switch_lib::cli::commands::update::execute(cmd), Some(Commands::Completions(cmd)) => cc_switch_lib::cli::commands::completions::execute(cmd), + Some(Commands::EditJson(cmd)) => cc_switch_lib::cli::commands::edit_json::execute(cmd), } } diff --git a/src-tauri/src/services/provider/common_config.rs b/src-tauri/src/services/provider/common_config.rs index 32ca914d..c91063f7 100644 --- a/src-tauri/src/services/provider/common_config.rs +++ b/src-tauri/src/services/provider/common_config.rs @@ -60,7 +60,8 @@ fn json_remove_array_items(target_arr: &mut Vec, source_arr: &[Value]) { } } -fn json_deep_merge(target: &mut Value, source: &Value) { +/// Deep-merge two JSON values. Objects are merged recursively; arrays and scalars are replaced. +pub(crate) fn json_deep_merge(target: &mut Value, source: &Value) { match (target, source) { (Value::Object(target_map), Value::Object(source_map)) => { for (key, source_value) in source_map { diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index afe7f5c9..d52956a9 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -33,6 +33,7 @@ use gemini_auth::GeminiAuthType; use live::LiveSnapshot; pub use common::migrate_legacy_codex_config; +pub(crate) use common_config::json_deep_merge; #[cfg(test)] use common::strip_codex_common_config_from_full_text; @@ -73,7 +74,8 @@ struct PostCommitAction { } impl ProviderService { - fn is_codex_official_provider(provider: &Provider) -> bool { + /// Check whether a provider is an official Codex provider (via meta flag or category). + pub(crate) fn is_codex_official_provider(provider: &Provider) -> bool { provider .meta .as_ref() @@ -85,7 +87,8 @@ impl ProviderService { .is_some_and(|value| value.eq_ignore_ascii_case("official")) } - fn codex_config_has_base_url(config_text: &str) -> bool { + /// Check whether a Codex TOML config text contains a non-empty `base_url`. + pub(crate) fn codex_config_has_base_url(config_text: &str) -> bool { let Ok(table) = toml::from_str::(config_text.trim()) else { return false; }; @@ -502,15 +505,25 @@ impl ProviderService { common_snippet_for_strip.clone() }; - let mut raw_settings = serde_json::Map::new(); - if let Some(auth) = auth { - raw_settings.insert("auth".to_string(), auth); + // Start from existing provider settings; only update auth and config in-place + let mut settings_to_store = provider.settings_config.clone(); + if let Value::Object(ref mut obj) = settings_to_store { + if let Some(auth) = auth { + obj.insert("auth".to_string(), auth); + } + obj.insert("config".to_string(), Value::String(cfg_text_for_storage)); + } else { + let mut obj = serde_json::Map::new(); + if let Some(auth) = auth { + obj.insert("auth".to_string(), auth); + } + obj.insert("config".to_string(), Value::String(cfg_text_for_storage)); + settings_to_store = Value::Object(obj); } - raw_settings.insert("config".to_string(), Value::String(cfg_text_for_storage)); - let mut settings_to_store = Self::normalize_settings_config_for_storage( + settings_to_store = Self::normalize_settings_config_for_storage( app_type, &provider, - Value::Object(raw_settings), + settings_to_store, effective_common_snippet.as_deref(), )?; Self::restore_codex_model_provider_for_storage_best_effort( @@ -558,7 +571,7 @@ impl ProviderService { )); } let env_map = read_gemini_env()?; - let mut live_after = env_to_json(&env_map); + let live_env = env_to_json(&env_map); let settings_path = get_gemini_settings_path(); let config_value = if settings_path.exists() { @@ -567,10 +580,6 @@ impl ProviderService { json!({}) }; - if let Some(obj) = live_after.as_object_mut() { - obj.insert("config".to_string(), config_value); - } - let (provider, common_snippet) = { let guard = state.config.read().map_err(AppError::from)?; ( @@ -588,10 +597,19 @@ impl ProviderService { guard.common_config_snippets.gemini.clone(), ) }; + + // Start from existing provider settings; only update env and config in-place + let mut merged = provider.settings_config.clone(); + if let Value::Object(ref mut obj) = merged { + obj.insert("env".to_string(), live_env); + obj.insert("config".to_string(), config_value); + } else { + merged = json!({"env": live_env, "config": config_value}); + } let live_after = Self::normalize_settings_config_for_storage( app_type, &provider, - live_after, + merged, common_snippet.as_deref(), )?; @@ -1966,22 +1984,21 @@ impl ProviderService { common_config_snippet, apply_common_config, )?; - let settings = effective - .as_object() - .ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?; - let auth = settings.get("auth").cloned(); - let cfg_text = settings.get("config").and_then(Value::as_str).unwrap_or(""); - - if !cfg_text.trim().is_empty() { - crate::codex_config::validate_config_toml(cfg_text)?; + let effective_obj = match effective { + Value::Object(map) => map, + _ => { + return Err(AppError::Config( + "Codex 配置必须是 JSON 对象".into(), + )) + } + }; + if let Some(cfg_text) = effective_obj.get("config").and_then(Value::as_str) { + if !cfg_text.trim().is_empty() { + crate::codex_config::validate_config_toml(cfg_text)?; + } } - let mut backup = serde_json::Map::new(); - if let Some(auth) = auth { - backup.insert("auth".to_string(), auth); - } - backup.insert("config".to_string(), Value::String(cfg_text.to_string())); - Ok(Value::Object(backup)) + Ok(Value::Object(effective_obj)) } AppType::Gemini => { let content_to_write = common_config::build_effective_settings_with_common_config( @@ -2046,10 +2063,18 @@ impl ProviderService { json!({}) }; - Ok(json!({ - "env": env_obj, - "config": config_value, - })) + // Preserve all existing keys from content_to_write; only update env and config in-place + let mut result = match content_to_write { + Value::Object(map) => map, + other => { + let mut map = serde_json::Map::new(); + map.insert("env".to_string(), other); + map + } + }; + result.insert("env".to_string(), env_obj); + result.insert("config".to_string(), config_value); + Ok(Value::Object(result)) } AppType::OpenCode => Err(AppError::Config( "OpenCode does not support proxy takeover backups".into(), diff --git a/src-tauri/src/services/provider/tests.rs b/src-tauri/src/services/provider/tests.rs index 3b9d9613..305e2f24 100644 --- a/src-tauri/src/services/provider/tests.rs +++ b/src-tauri/src/services/provider/tests.rs @@ -4371,3 +4371,189 @@ fn import_openclaw_providers_from_live_skips_existing_ids_without_overwriting() Some(true) ); } + +#[test] +fn build_effective_live_snapshot_codex_preserves_custom_keys() { + let provider = Provider::with_id( + "p1".to_string(), + "Test".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "sk-test"}, + "config": "[model_provider]\nprovider = \"custom\"\nbase_url = \"https://api.example.com\"\n", + "customKey": "custom-value" + }), + None, + ); + + let effective = ProviderService::build_effective_live_snapshot( + &AppType::Codex, + &provider, + None, + false, + ) + .expect("build effective snapshot"); + + // canonical keys present + assert!(effective.get("auth").is_some(), "auth should be present"); + assert!(effective.get("config").is_some(), "config should be present"); + // custom key preserved + assert_eq!( + effective.get("customKey").and_then(Value::as_str), + Some("custom-value"), + "custom keys should be preserved in effective snapshot" + ); +} + +#[test] +fn build_effective_live_snapshot_gemini_preserves_custom_keys() { + let provider = Provider::with_id( + "p1".to_string(), + "Test".to_string(), + json!({ + "env": {"GEMINI_API_KEY": "sk-test"}, + "config": {"temperature": 0.7}, + "customKey": "custom-value" + }), + None, + ); + + let effective = ProviderService::build_effective_live_snapshot( + &AppType::Gemini, + &provider, + None, + false, + ) + .expect("build effective snapshot"); + + assert!(effective.get("env").is_some(), "env should be present"); + assert!(effective.get("config").is_some(), "config should be present"); + assert_eq!( + effective.get("customKey").and_then(Value::as_str), + Some("custom-value"), + "custom keys should be preserved in effective snapshot" + ); +} + +#[test] +#[serial] +fn codex_switch_preserves_custom_keys_in_settings_config() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = EnvGuard::set_home(temp_home.path()); + std::fs::create_dir_all(crate::codex_config::get_codex_config_dir()) + .expect("create ~/.codex dir"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Codex); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "p1".to_string(); + manager.providers.insert( + "p1".to_string(), + Provider::with_id( + "p1".to_string(), + "Provider One".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "sk-test"}, + "config": "model_provider = \"custom\"\nbase_url = \"https://api.example.com\"\n", + "customKey": "custom-value" + }), + None, + ), + ); + } + + let state = state_from_config(config); + + // Switch triggers write_live_snapshot + refresh_provider_snapshot round-trip + ProviderService::switch(&state, AppType::Codex, "p1").expect("switch to p1"); + + // Check in-memory config + { + let guard = state.config.read().expect("read config"); + let manager = guard.get_manager(&AppType::Codex).expect("codex manager"); + let provider = manager.providers.get("p1").expect("p1 exists"); + assert_eq!( + provider.settings_config.get("customKey").and_then(Value::as_str), + Some("custom-value"), + "custom keys should survive switch round-trip in memory" + ); + } + + // Check DB + let db_provider = state + .db + .get_provider_by_id("p1", AppType::Codex.as_str()) + .expect("query") + .expect("exists"); + assert_eq!( + db_provider.settings_config.get("customKey").and_then(Value::as_str), + Some("custom-value"), + "custom keys should survive switch round-trip in DB" + ); +} + +#[test] +#[serial] +fn gemini_switch_preserves_custom_keys_in_settings_config() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = EnvGuard::set_home(temp_home.path()); + let gemini_dir = crate::gemini_config::get_gemini_dir(); + std::fs::create_dir_all(&gemini_dir).expect("create gemini dir"); + + // Write minimal .env so Gemini refresh doesn't bail on missing file + std::fs::write( + crate::gemini_config::get_gemini_env_path(), + "GEMINI_API_KEY=sk-test\n", + ) + .expect("write .env"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Gemini); + { + let manager = config + .get_manager_mut(&AppType::Gemini) + .expect("gemini manager"); + manager.current = "p1".to_string(); + manager.providers.insert( + "p1".to_string(), + Provider::with_id( + "p1".to_string(), + "Provider One".to_string(), + json!({ + "env": {"GEMINI_API_KEY": "sk-test"}, + "config": {"temperature": 0.7}, + "customKey": "custom-value" + }), + None, + ), + ); + } + + let state = state_from_config(config); + + ProviderService::switch(&state, AppType::Gemini, "p1").expect("switch to p1"); + + { + let guard = state.config.read().expect("read config"); + let manager = guard.get_manager(&AppType::Gemini).expect("gemini manager"); + let provider = manager.providers.get("p1").expect("p1 exists"); + assert_eq!( + provider.settings_config.get("customKey").and_then(Value::as_str), + Some("custom-value"), + "custom keys should survive switch round-trip in memory" + ); + } + + let db_provider = state + .db + .get_provider_by_id("p1", AppType::Gemini.as_str()) + .expect("query") + .expect("exists"); + assert_eq!( + db_provider.settings_config.get("customKey").and_then(Value::as_str), + Some("custom-value"), + "custom keys should survive switch round-trip in DB" + ); +} diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index a9aa9e5c..a20a4bfb 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1288,6 +1288,7 @@ impl ProxyService { app_type.as_str(), &provider_id, &provider.settings_config, + false, // merge to preserve custom keys ) { log::warn!( "sync {} live token to provider {} failed: {error}", diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index ed505c00..e35f697b 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -436,6 +436,7 @@ fn migrate_legacy_codex_configs(db: &Database, config: &mut MultiAppConfig) { AppType::Codex.as_str(), provider_id, &provider.settings_config, + false, // merge to preserve custom keys ) { log::warn!( "Failed to persist migrated Codex config for provider '{}': {}",