refactor(backend): phase 4 - extract provider service layer
Architecture improvements: - Extract ProviderService with switch/backfill/write methods - Reduce command layer from 160 to 13 lines via delegation - Separate business logic (services) from state management (commands) - Introduce precise error handling with structured validation Refactoring details: - Split Codex/Claude switching into symmetric private methods - Add multi-layer validation for Codex auth field (existence + type) - Extract import_config_from_path for command and test reuse - Expose export_config_to_file and ProviderService in public API Test coverage: - Add 10+ integration tests for Claude/Codex switching flows - Cover import/export success and failure scenarios (JSON parse, missing file) - Verify state consistency on error paths (current remains unchanged) - Test snapshot backfill for both old and new providers after switching
This commit is contained in:
@@ -82,7 +82,12 @@
|
|||||||
- 新增负向测试验证 Codex 供应商缺少 `auth` 字段时的错误返回,并补充备份数量上限测试;顺带修复 `create_backup` 采用内存读写避免拷贝继承旧的修改时间,确保最新备份不会在清理阶段被误删。
|
- 新增负向测试验证 Codex 供应商缺少 `auth` 字段时的错误返回,并补充备份数量上限测试;顺带修复 `create_backup` 采用内存读写避免拷贝继承旧的修改时间,确保最新备份不会在清理阶段被误删。
|
||||||
- 针对 `codex_config::write_codex_live_atomic` 补充成功与失败场景测试,覆盖 auth/config 原子写入与失败回滚逻辑(模拟目标路径为目录时的 rename 失败),降低 Codex live 写入回归风险。
|
- 针对 `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 / 互斥工具函数。
|
- 新增 `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 核心路径及关键错误分支,后续仍需补齐命令层边界与导入导出异常回滚测试。
|
- 当前已覆盖配置、Codex/Claude MCP 核心路径及关键错误分支,后续仍需补齐命令层边界与导入导出异常回滚测试。
|
||||||
|
- **阶段 4:服务层抽象 🚧**
|
||||||
|
- 新增 `services/provider.rs` 并实现 `ProviderService::switch`,负责供应商切换时的业务流程(live 回填、持久化、MCP 同步),命令层通过薄封装调用并负责状态持久化。
|
||||||
|
|
||||||
## 渐进式重构路线
|
## 渐进式重构路线
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use crate::codex_config;
|
|||||||
use crate::config::get_claude_settings_path;
|
use crate::config::get_claude_settings_path;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::{Provider, ProviderMeta};
|
use crate::provider::{Provider, ProviderMeta};
|
||||||
|
use crate::services::ProviderService;
|
||||||
use crate::speedtest;
|
use crate::speedtest;
|
||||||
use crate::store::AppState;
|
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> {
|
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
let mut config = state
|
let mut config = state
|
||||||
.config
|
.config
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::Message(format!("获取锁失败: {}", e)))?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let provider = {
|
ProviderService::switch(&mut config, app_type, id)?;
|
||||||
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::<serde_json::Value>(&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::<serde_json::Value>(&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!("成功切换到供应商");
|
|
||||||
|
|
||||||
drop(config);
|
drop(config);
|
||||||
state.save()?;
|
state.save()
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::provider::Provider;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
// 默认仅保留最近 10 份备份,避免目录无限膨胀
|
// 默认仅保留最近 10 份备份,避免目录无限膨胀
|
||||||
const MAX_BACKUPS: usize = 10;
|
const MAX_BACKUPS: usize = 10;
|
||||||
@@ -223,38 +223,41 @@ pub async fn import_config_from_file(
|
|||||||
file_path: String,
|
file_path: String,
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
// 读取导入的文件
|
import_config_from_path(Path::new(&file_path), &state)
|
||||||
let file_path_ref = std::path::Path::new(&file_path);
|
.map_err(|e| e.to_string())
|
||||||
let import_content = fs::read_to_string(file_path_ref)
|
.map(|backup_id| {
|
||||||
.map_err(|e| AppError::io(file_path_ref, e).to_string())?;
|
json!({
|
||||||
|
|
||||||
// 验证并解析为配置对象
|
|
||||||
let new_config: crate::app_config::MultiAppConfig =
|
|
||||||
serde_json::from_str(&import_content)
|
|
||||||
.map_err(|e| AppError::json(file_path_ref, e).to_string())?;
|
|
||||||
|
|
||||||
// 备份当前配置
|
|
||||||
let config_path = crate::config::get_app_config_path();
|
|
||||||
let backup_id = create_backup(&config_path).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 写入新配置到磁盘
|
|
||||||
fs::write(&config_path, &import_content)
|
|
||||||
.map_err(|e| AppError::io(&config_path, e).to_string())?;
|
|
||||||
|
|
||||||
// 更新内存中的状态
|
|
||||||
{
|
|
||||||
let mut config_state = state
|
|
||||||
.config
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| AppError::from(e).to_string())?;
|
|
||||||
*config_state = new_config;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(json!({
|
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Configuration imported successfully",
|
"message": "Configuration imported successfully",
|
||||||
"backupId": backup_id
|
"backupId": backup_id
|
||||||
}))
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从文件导入配置的核心逻辑,供命令及测试复用。
|
||||||
|
pub fn import_config_from_path(
|
||||||
|
file_path: &Path,
|
||||||
|
state: &crate::store::AppState,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
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, e))?;
|
||||||
|
|
||||||
|
let config_path = crate::config::get_app_config_path();
|
||||||
|
let backup_id = create_backup(&config_path)?;
|
||||||
|
|
||||||
|
fs::write(&config_path, &import_content)
|
||||||
|
.map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = state.config.lock().map_err(AppError::from)?;
|
||||||
|
*guard = new_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(backup_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步当前供应商配置到对应的 live 文件
|
/// 同步当前供应商配置到对应的 live 文件
|
||||||
|
|||||||
@@ -13,17 +13,21 @@ mod provider;
|
|||||||
mod settings;
|
mod settings;
|
||||||
mod speedtest;
|
mod speedtest;
|
||||||
mod store;
|
mod store;
|
||||||
|
mod services;
|
||||||
mod usage_script;
|
mod usage_script;
|
||||||
|
|
||||||
pub use app_config::{AppType, MultiAppConfig};
|
pub use app_config::{AppType, MultiAppConfig};
|
||||||
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
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 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 provider::Provider;
|
||||||
pub use settings::{update_settings, AppSettings};
|
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 mcp::{import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex};
|
||||||
pub use error::AppError;
|
pub use error::AppError;
|
||||||
pub use store::AppState;
|
pub use store::AppState;
|
||||||
|
pub use services::ProviderService;
|
||||||
pub use commands::*;
|
pub use commands::*;
|
||||||
|
|
||||||
use tauri::{
|
use tauri::{
|
||||||
|
|||||||
3
src-tauri/src/services/mod.rs
Normal file
3
src-tauri/src/services/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod provider;
|
||||||
|
|
||||||
|
pub use provider::ProviderService;
|
||||||
184
src-tauri/src/services/provider.rs
Normal file
184
src-tauri/src/services/provider.rs
Normal file
@@ -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::<Value>(&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::<Value>(&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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
use std::fs;
|
use std::{fs, path::Path, sync::Mutex};
|
||||||
|
use tauri::async_runtime;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{
|
||||||
create_backup, get_claude_settings_path, read_json_file, sync_current_providers_to_live, AppType,
|
create_backup, get_claude_settings_path, import_config_from_path, read_json_file,
|
||||||
MultiAppConfig, Provider,
|
sync_current_providers_to_live, AppError, AppState, AppType, MultiAppConfig, Provider,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[path = "support.rs"]
|
#[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"
|
"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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use serde_json::json;
|
|||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{
|
||||||
get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook,
|
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"]
|
#[path = "support.rs"]
|
||||||
@@ -157,3 +157,183 @@ fn switch_provider_missing_provider_returns_error() {
|
|||||||
"error message should mention missing provider"
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user