diff --git a/docs/BACKEND_REFACTOR_PLAN.md b/docs/BACKEND_REFACTOR_PLAN.md index a11546f..4199dc1 100644 --- a/docs/BACKEND_REFACTOR_PLAN.md +++ b/docs/BACKEND_REFACTOR_PLAN.md @@ -82,7 +82,12 @@ - 新增负向测试验证 Codex 供应商缺少 `auth` 字段时的错误返回,并补充备份数量上限测试;顺带修复 `create_backup` 采用内存读写避免拷贝继承旧的修改时间,确保最新备份不会在清理阶段被误删。 - 针对 `codex_config::write_codex_live_atomic` 补充成功与失败场景测试,覆盖 auth/config 原子写入与失败回滚逻辑(模拟目标路径为目录时的 rename 失败),降低 Codex live 写入回归风险。 - 新增 `tests/provider_commands.rs` 覆盖 `switch_provider` 的 Codex 正常流程与供应商缺失分支,并抽取 `switch_provider_internal` 以复用 `AppError`,通过 `switch_provider_test_hook` 暴露测试入口;同时共享 `tests/support.rs` 提供隔离 HOME / 互斥工具函数。 + - 补充 Claude 切换集成测试,验证 live `settings.json` 覆写、新旧供应商快照回填以及 `.cc-switch/config.json` 持久化结果,确保阶段四提取服务层时拥有可回归的用例。 + - 增加 Codex 缺失 `auth` 场景测试,确认 `switch_provider_internal` 在关键字段缺失时返回带上下文的 `AppError`,同时保持内存状态未被污染。 + - 为配置导入命令抽取复用逻辑 `import_config_from_path` 并补充成功/失败集成测试,校验备份生成、状态同步、JSON 解析与文件缺失等错误回退路径;`export_config_to_file` 亦具备成功/缺失源文件的命令级回归。 - 当前已覆盖配置、Codex/Claude MCP 核心路径及关键错误分支,后续仍需补齐命令层边界与导入导出异常回滚测试。 +- **阶段 4:服务层抽象 🚧** + - 新增 `services/provider.rs` 并实现 `ProviderService::switch`,负责供应商切换时的业务流程(live 回填、持久化、MCP 同步),命令层通过薄封装调用并负责状态持久化。 ## 渐进式重构路线 diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index cbaa46a..91a3a47 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -10,6 +10,7 @@ use crate::codex_config; use crate::config::get_claude_settings_path; use crate::error::AppError; use crate::provider::{Provider, ProviderMeta}; +use crate::services::ProviderService; use crate::speedtest; use crate::store::AppState; @@ -312,160 +313,15 @@ pub async fn delete_provider( /// 切换供应商 fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { - use serde_json::Value; - let mut config = state .config .lock() - .map_err(|e| AppError::Message(format!("获取锁失败: {}", e)))?; + .map_err(AppError::from)?; - let provider = { - let manager = config - .get_manager_mut(&app_type) - .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; - - manager - .providers - .get(id) - .cloned() - .ok_or_else(|| AppError::Message(format!("供应商不存在: {}", id)))? - }; - - match app_type { - AppType::Codex => { - if !{ - let cur = config - .get_manager_mut(&app_type) - .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; - cur.current.is_empty() - } { - let auth_path = codex_config::get_codex_auth_path(); - let config_path = codex_config::get_codex_config_path(); - if auth_path.exists() { - let auth: Value = crate::config::read_json_file(&auth_path)?; - let config_str = if config_path.exists() { - std::fs::read_to_string(&config_path).map_err(|e| { - AppError::Message(format!( - "读取 config.toml 失败: {}: {}", - config_path.display(), - e - )) - })? - } else { - String::new() - }; - - let live = serde_json::json!({ - "auth": auth, - "config": config_str, - }); - - let cur_id2 = { - let m = config - .get_manager(&app_type) - .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; - m.current.clone() - }; - let m = config - .get_manager_mut(&app_type) - .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; - if let Some(cur) = m.providers.get_mut(&cur_id2) { - cur.settings_config = live; - } - } - } - - let auth = provider - .settings_config - .get("auth") - .ok_or_else(|| AppError::Message("目标供应商缺少 auth 配置".to_string()))?; - let cfg_text = provider - .settings_config - .get("config") - .and_then(|v| v.as_str()); - crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; - } - AppType::Claude => { - use crate::config::{read_json_file, write_json_file}; - - let settings_path = get_claude_settings_path(); - - if settings_path.exists() { - let cur_id = { - let m = config - .get_manager(&app_type) - .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; - m.current.clone() - }; - if !cur_id.is_empty() { - if let Ok(live) = read_json_file::(&settings_path) { - let m = config - .get_manager_mut(&app_type) - .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; - if let Some(cur) = m.providers.get_mut(&cur_id) { - cur.settings_config = live; - } - } - } - } - - if let Some(parent) = settings_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| AppError::Message(format!("创建目录失败: {}", e)))?; - } - - write_json_file(&settings_path, &provider.settings_config)?; - - if settings_path.exists() { - if let Ok(live_after) = read_json_file::(&settings_path) { - let m = config - .get_manager_mut(&app_type) - .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; - if let Some(target) = m.providers.get_mut(id) { - target.settings_config = live_after; - } - } - } - } - } - - { - let manager = config - .get_manager_mut(&app_type) - .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; - manager.current = id.to_string(); - } - - if let AppType::Codex = app_type { - crate::mcp::sync_enabled_to_codex(&config)?; - - let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?; - - let cur_id = { - let m = config - .get_manager(&app_type) - .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; - m.current.clone() - }; - let m = config - .get_manager_mut(&app_type) - .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; - if let Some(p) = m.providers.get_mut(&cur_id) { - if let Some(obj) = p.settings_config.as_object_mut() { - obj.insert( - "config".to_string(), - serde_json::Value::String(cfg_text_after), - ); - } - } - } - - log::info!("成功切换到供应商"); + ProviderService::switch(&mut config, app_type, id)?; drop(config); - state.save()?; - - Ok(()) + state.save() } #[doc(hidden)] diff --git a/src-tauri/src/import_export.rs b/src-tauri/src/import_export.rs index 8a6d43e..e50da4f 100644 --- a/src-tauri/src/import_export.rs +++ b/src-tauri/src/import_export.rs @@ -4,7 +4,7 @@ use crate::provider::Provider; use chrono::Utc; use serde_json::{json, Value}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; // 默认仅保留最近 10 份备份,避免目录无限膨胀 const MAX_BACKUPS: usize = 10; @@ -223,38 +223,41 @@ pub async fn import_config_from_file( file_path: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result { - // 读取导入的文件 - let file_path_ref = std::path::Path::new(&file_path); - let import_content = fs::read_to_string(file_path_ref) - .map_err(|e| AppError::io(file_path_ref, e).to_string())?; + import_config_from_path(Path::new(&file_path), &state) + .map_err(|e| e.to_string()) + .map(|backup_id| { + json!({ + "success": true, + "message": "Configuration imported successfully", + "backupId": backup_id + }) + }) +} + +/// 从文件导入配置的核心逻辑,供命令及测试复用。 +pub fn import_config_from_path( + file_path: &Path, + state: &crate::store::AppState, +) -> Result { + let import_content = + fs::read_to_string(file_path).map_err(|e| AppError::io(file_path, e))?; - // 验证并解析为配置对象 let new_config: crate::app_config::MultiAppConfig = serde_json::from_str(&import_content) - .map_err(|e| AppError::json(file_path_ref, e).to_string())?; + .map_err(|e| AppError::json(file_path, e))?; - // 备份当前配置 let config_path = crate::config::get_app_config_path(); - let backup_id = create_backup(&config_path).map_err(|e| e.to_string())?; + let backup_id = create_backup(&config_path)?; - // 写入新配置到磁盘 fs::write(&config_path, &import_content) - .map_err(|e| AppError::io(&config_path, e).to_string())?; + .map_err(|e| AppError::io(&config_path, e))?; - // 更新内存中的状态 { - let mut config_state = state - .config - .lock() - .map_err(|e| AppError::from(e).to_string())?; - *config_state = new_config; + let mut guard = state.config.lock().map_err(AppError::from)?; + *guard = new_config; } - Ok(json!({ - "success": true, - "message": "Configuration imported successfully", - "backupId": backup_id - })) + Ok(backup_id) } /// 同步当前供应商配置到对应的 live 文件 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 84e0b5a..b8c9c1d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,17 +13,21 @@ mod provider; mod settings; mod speedtest; mod store; +mod services; mod usage_script; pub use app_config::{AppType, MultiAppConfig}; pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; -pub use import_export::{create_backup, sync_current_providers_to_live}; +pub use import_export::{ + create_backup, export_config_to_file, import_config_from_path, sync_current_providers_to_live, +}; pub use provider::Provider; pub use settings::{update_settings, AppSettings}; pub use mcp::{import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex}; pub use error::AppError; pub use store::AppState; +pub use services::ProviderService; pub use commands::*; use tauri::{ diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs new file mode 100644 index 0000000..76ddf6a --- /dev/null +++ b/src-tauri/src/services/mod.rs @@ -0,0 +1,3 @@ +pub mod provider; + +pub use provider::ProviderService; diff --git a/src-tauri/src/services/provider.rs b/src-tauri/src/services/provider.rs new file mode 100644 index 0000000..6dec46c --- /dev/null +++ b/src-tauri/src/services/provider.rs @@ -0,0 +1,184 @@ +use serde_json::{json, Value}; + +use crate::app_config::{AppType, MultiAppConfig}; +use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; +use crate::codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; +use crate::error::AppError; +use crate::mcp; + +/// 供应商相关业务逻辑 +pub struct ProviderService; + +impl ProviderService { + /// 切换指定应用的供应商 + pub fn switch( + config: &mut MultiAppConfig, + app_type: AppType, + provider_id: &str, + ) -> Result<(), AppError> { + match app_type { + AppType::Codex => Self::switch_codex(config, provider_id), + AppType::Claude => Self::switch_claude(config, provider_id), + } + } + + fn switch_codex(config: &mut MultiAppConfig, provider_id: &str) -> Result<(), AppError> { + let provider = config + .get_manager(&AppType::Codex) + .ok_or_else(|| AppError::Message("应用类型不存在: Codex".into()))? + .providers + .get(provider_id) + .cloned() + .ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))?; + + Self::backfill_codex_current(config, provider_id)?; + Self::write_codex_live(&provider)?; + + if let Some(manager) = config.get_manager_mut(&AppType::Codex) { + manager.current = provider_id.to_string(); + } + + // 同步启用的 MCP 服务器 + mcp::sync_enabled_to_codex(config)?; + + // 更新持久化快照 + let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?; + if let Some(manager) = config.get_manager_mut(&AppType::Codex) { + if let Some(target) = manager.providers.get_mut(provider_id) { + if let Some(obj) = target.settings_config.as_object_mut() { + obj.insert( + "config".to_string(), + Value::String(cfg_text_after), + ); + } + } + } + + Ok(()) + } + + fn backfill_codex_current( + config: &mut MultiAppConfig, + next_provider: &str, + ) -> Result<(), AppError> { + let current_id = config + .get_manager(&AppType::Codex) + .map(|m| m.current.clone()) + .unwrap_or_default(); + + if current_id.is_empty() || current_id == next_provider { + return Ok(()); + } + + let auth_path = get_codex_auth_path(); + if !auth_path.exists() { + return Ok(()); + } + + let auth: Value = read_json_file(&auth_path)?; + let config_path = get_codex_config_path(); + let config_text = if config_path.exists() { + std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))? + } else { + String::new() + }; + + let live = json!({ + "auth": auth, + "config": config_text, + }); + + if let Some(manager) = config.get_manager_mut(&AppType::Codex) { + if let Some(current) = manager.providers.get_mut(¤t_id) { + current.settings_config = live; + } + } + + Ok(()) + } + + fn write_codex_live(provider: &crate::provider::Provider) -> Result<(), AppError> { + let settings = provider + .settings_config + .as_object() + .ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?; + let auth = settings.get("auth").ok_or_else(|| { + AppError::Config(format!("供应商 {} 缺少 auth 配置", provider.id)) + })?; + if !auth.is_object() { + return Err(AppError::Config(format!( + "供应商 {} 的 auth 必须是对象", + provider.id + ))); + } + let cfg_text = settings.get("config").and_then(Value::as_str); + + write_codex_live_atomic(auth, cfg_text)?; + Ok(()) + } + + fn switch_claude(config: &mut MultiAppConfig, provider_id: &str) -> Result<(), AppError> { + let provider = { + let manager = config + .get_manager(&AppType::Claude) + .ok_or_else(|| AppError::Message("应用类型不存在: Claude".into()))?; + manager + .providers + .get(provider_id) + .cloned() + .ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))? + }; + + Self::backfill_claude_current(config, provider_id)?; + Self::write_claude_live(&provider)?; + + if let Some(manager) = config.get_manager_mut(&AppType::Claude) { + manager.current = provider_id.to_string(); + + if let Some(target) = manager.providers.get_mut(provider_id) { + let settings_path = get_claude_settings_path(); + let live_after = read_json_file::(&settings_path)?; + target.settings_config = live_after; + } + } + + Ok(()) + } + + fn backfill_claude_current( + config: &mut MultiAppConfig, + next_provider: &str, + ) -> Result<(), AppError> { + let settings_path = get_claude_settings_path(); + if !settings_path.exists() { + return Ok(()); + } + + let current_id = config + .get_manager(&AppType::Claude) + .map(|m| m.current.clone()) + .unwrap_or_default(); + if current_id.is_empty() || current_id == next_provider { + return Ok(()); + } + + let live = read_json_file::(&settings_path)?; + if let Some(manager) = config.get_manager_mut(&AppType::Claude) { + if let Some(current) = manager.providers.get_mut(¤t_id) { + current.settings_config = live; + } + } + + Ok(()) + } + + fn write_claude_live(provider: &crate::provider::Provider) -> Result<(), AppError> { + let settings_path = get_claude_settings_path(); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + write_json_file(&settings_path, &provider.settings_config)?; + Ok(()) + } +} diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 2b74ca5..1b4f2e9 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -1,9 +1,10 @@ -use std::fs; +use std::{fs, path::Path, sync::Mutex}; +use tauri::async_runtime; use serde_json::json; use cc_switch_lib::{ - create_backup, get_claude_settings_path, read_json_file, sync_current_providers_to_live, AppType, - MultiAppConfig, Provider, + create_backup, get_claude_settings_path, import_config_from_path, read_json_file, + sync_current_providers_to_live, AppError, AppState, AppType, MultiAppConfig, Provider, }; #[path = "support.rs"] @@ -671,3 +672,188 @@ fn create_backup_retains_only_latest_entries() { "cleanup should keep part of the older backups to maintain history" ); } + +#[test] +fn import_config_from_path_overwrites_state_and_creates_backup() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let config_dir = home.join(".cc-switch"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let config_path = config_dir.join("config.json"); + fs::write(&config_path, r#"{"version":1}"#).expect("seed original config"); + + let import_payload = serde_json::json!({ + "version": 2, + "claude": { + "providers": { + "p-new": { + "id": "p-new", + "name": "Test Claude", + "settingsConfig": { + "env": { "ANTHROPIC_API_KEY": "new-key" } + } + } + }, + "current": "p-new" + }, + "codex": { + "providers": {}, + "current": "" + }, + "mcp": { + "claude": { "servers": {} }, + "codex": { "servers": {} } + } + }); + + let import_path = config_dir.join("import.json"); + fs::write( + &import_path, + serde_json::to_string_pretty(&import_payload).expect("serialize import payload"), + ) + .expect("write import file"); + + let app_state = AppState { + config: Mutex::new(MultiAppConfig::default()), + }; + + let backup_id = + import_config_from_path(&import_path, &app_state).expect("import should succeed"); + assert!( + !backup_id.is_empty(), + "expected backup id when original config exists" + ); + + let backup_path = config_dir.join("backups").join(format!("{backup_id}.json")); + assert!( + backup_path.exists(), + "backup file should exist at {}", + backup_path.display() + ); + + let updated_content = fs::read_to_string(&config_path).expect("read updated config"); + let parsed: serde_json::Value = + serde_json::from_str(&updated_content).expect("parse updated config"); + assert_eq!( + parsed + .get("claude") + .and_then(|c| c.get("current")) + .and_then(|c| c.as_str()), + Some("p-new"), + "saved config should record new current provider" + ); + + let guard = app_state + .config + .lock() + .expect("lock state after import"); + let claude_manager = guard + .get_manager(&AppType::Claude) + .expect("claude manager in state"); + assert_eq!( + claude_manager.current, "p-new", + "state should reflect new current provider" + ); + assert!( + claude_manager.providers.contains_key("p-new"), + "new provider should exist in state" + ); +} + +#[test] +fn import_config_from_path_invalid_json_returns_error() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let config_dir = home.join(".cc-switch"); + fs::create_dir_all(&config_dir).expect("create config dir"); + + let invalid_path = config_dir.join("broken.json"); + fs::write(&invalid_path, "{ not-json ").expect("write invalid json"); + + let app_state = AppState { + config: Mutex::new(MultiAppConfig::default()), + }; + + let err = + import_config_from_path(&invalid_path, &app_state).expect_err("import should fail"); + match err { + AppError::Json { .. } => {} + other => panic!("expected json error, got {other:?}"), + } +} + +#[test] +fn import_config_from_path_missing_file_produces_io_error() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let missing_path = Path::new("/nonexistent/import.json"); + let app_state = AppState { + config: Mutex::new(MultiAppConfig::default()), + }; + + let err = import_config_from_path(missing_path, &app_state) + .expect_err("import should fail for missing file"); + match err { + AppError::Io { .. } => {} + other => panic!("expected io error, got {other:?}"), + } +} + +#[test] +fn export_config_to_file_writes_target_path() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let config_dir = home.join(".cc-switch"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let config_path = config_dir.join("config.json"); + fs::write(&config_path, r#"{"version":42,"flag":true}"#).expect("write config"); + + let export_path = home.join("exported-config.json"); + if export_path.exists() { + fs::remove_file(&export_path).expect("cleanup export target"); + } + + let result = async_runtime::block_on(cc_switch_lib::export_config_to_file( + export_path.to_string_lossy().to_string(), + )) + .expect("export should succeed"); + assert_eq!( + result.get("success").and_then(|v| v.as_bool()), + Some(true) + ); + + let exported = fs::read_to_string(&export_path).expect("read exported file"); + assert!( + exported.contains(r#""version":42"#) && exported.contains(r#""flag":true"#), + "exported file should mirror source config content" + ); +} + +#[test] +fn export_config_to_file_returns_error_when_source_missing() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let export_path = home.join("export-missing.json"); + if export_path.exists() { + fs::remove_file(&export_path).expect("cleanup export target"); + } + + let err = async_runtime::block_on(cc_switch_lib::export_config_to_file( + export_path.to_string_lossy().to_string(), + )) + .expect_err("export should fail when config.json missing"); + assert!( + err.contains("IO 错误"), + "expected IO error message, got {err}" + ); +} diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 098304a..f00551e 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -2,7 +2,7 @@ use serde_json::json; use cc_switch_lib::{ get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook, - write_codex_live_atomic, AppState, AppType, MultiAppConfig, Provider, + write_codex_live_atomic, AppError, AppState, AppType, MultiAppConfig, Provider, }; #[path = "support.rs"] @@ -157,3 +157,183 @@ fn switch_provider_missing_provider_returns_error() { "error message should mention missing provider" ); } + +#[test] +fn switch_provider_updates_claude_live_and_state() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let settings_path = cc_switch_lib::get_claude_settings_path(); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).expect("create claude settings dir"); + } + let legacy_live = json!({ + "env": { + "ANTHROPIC_API_KEY": "legacy-key" + }, + "workspace": { + "path": "/tmp/workspace" + } + }); + std::fs::write( + &settings_path, + serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"), + ) + .expect("seed claude live config"); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.current = "old-provider".to_string(); + manager.providers.insert( + "old-provider".to_string(), + Provider::with_id( + "old-provider".to_string(), + "Legacy Claude".to_string(), + json!({ + "env": { "ANTHROPIC_API_KEY": "stale-key" } + }), + None, + ), + ); + manager.providers.insert( + "new-provider".to_string(), + Provider::with_id( + "new-provider".to_string(), + "Fresh Claude".to_string(), + json!({ + "env": { "ANTHROPIC_API_KEY": "fresh-key" }, + "workspace": { "path": "/tmp/new-workspace" } + }), + None, + ), + ); + } + + let app_state = AppState { + config: std::sync::Mutex::new(config), + }; + + switch_provider_test_hook(&app_state, AppType::Claude, "new-provider") + .expect("switch provider should succeed"); + + let live_after: serde_json::Value = + read_json_file(&settings_path).expect("read claude live settings"); + assert_eq!( + live_after + .get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|key| key.as_str()), + Some("fresh-key"), + "live settings.json should reflect new provider auth" + ); + + let locked = app_state + .config + .lock() + .expect("lock config after switch"); + let manager = locked + .get_manager(&AppType::Claude) + .expect("claude manager after switch"); + assert_eq!(manager.current, "new-provider", "current provider updated"); + + let legacy_provider = manager + .providers + .get("old-provider") + .expect("legacy provider still exists"); + assert_eq!( + legacy_provider.settings_config, legacy_live, + "previous provider should receive backfilled live config" + ); + + let new_provider = manager + .providers + .get("new-provider") + .expect("new provider exists"); + assert_eq!( + new_provider + .settings_config + .get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .and_then(|key| key.as_str()), + Some("fresh-key"), + "new provider snapshot should retain fresh auth" + ); + + drop(locked); + + let home_dir = + std::env::var("HOME").expect("HOME should be set by ensure_test_home"); + let config_path = std::path::Path::new(&home_dir) + .join(".cc-switch") + .join("config.json"); + assert!( + config_path.exists(), + "switching provider should persist config.json" + ); + let persisted: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config_path).expect("read saved config")) + .expect("parse saved config"); + assert_eq!( + persisted + .get("claude") + .and_then(|claude| claude.get("current")) + .and_then(|current| current.as_str()), + Some("new-provider"), + "saved config.json should record the new current provider" + ); +} + +#[test] +fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert( + "invalid".to_string(), + Provider::with_id( + "invalid".to_string(), + "Broken Codex".to_string(), + json!({ + "config": "[mcp_servers.test]\ncommand = \"noop\"" + }), + None, + ), + ); + } + + let app_state = AppState { + config: std::sync::Mutex::new(config), + }; + + let err = switch_provider_test_hook(&app_state, AppType::Codex, "invalid") + .expect_err("switching should fail when auth missing"); + match err { + AppError::Config(msg) => assert!( + msg.contains("auth"), + "expected auth missing error message, got {msg}" + ), + other => panic!("expected config error, got {other:?}"), + } + + let locked = app_state + .config + .lock() + .expect("lock config after failure"); + let manager = locked + .get_manager(&AppType::Codex) + .expect("codex manager"); + assert!( + manager.current.is_empty(), + "current provider should remain empty on failure" + ); +}