refactor(backend): phase 4 - add test hooks and extend service layer

- Extract internal functions in commands/mcp.rs and commands/provider.rs
  to enable unit testing without Tauri context
- Add test hooks: set_mcp_enabled_test_hook, import_mcp_from_claude_test_hook,
  import_mcp_from_codex_test_hook, import_default_config_test_hook
- Migrate error types from String to AppError for precise error matching in tests
- Extend ProviderService with delete() method to unify Codex/Claude cleanup logic
- Add comprehensive test coverage:
  - tests/mcp_commands.rs: command-level tests for MCP operations
  - tests/provider_service.rs: service-level tests for switch/delete operations
- Run cargo fmt to fix formatting issues (EOF newlines)
- Update BACKEND_REFACTOR_PLAN.md to mark phase 3 complete
This commit is contained in:
Jason
2025-10-28 11:58:57 +08:00
parent c2e8855a0f
commit 7e27f88154
20 changed files with 1005 additions and 415 deletions

View File

@@ -90,8 +90,8 @@ impl MultiAppConfig {
}
// 尝试读取文件
let content = std::fs::read_to_string(&config_path)
.map_err(|e| AppError::io(&config_path, e))?;
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
// 检查是否是旧版本格式v1
if let Ok(v1_config) = serde_json::from_str::<ProviderManager>(&content) {

View File

@@ -66,7 +66,10 @@ pub fn get_app_config_dir_from_store(app: &tauri::AppHandle) -> Option<PathBuf>
Some(path)
}
Some(_) => {
log::warn!("Store 中的 {} 类型不正确,应为字符串", STORE_KEY_APP_CONFIG_DIR);
log::warn!(
"Store 中的 {} 类型不正确,应为字符串",
STORE_KEY_APP_CONFIG_DIR
);
None
}
None => None,

View File

@@ -66,8 +66,7 @@ fn read_json_value(path: &Path) -> Result<Value, AppError> {
return Ok(serde_json::json!({}));
}
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
let value: Value =
serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;
let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;
Ok(value)
}
@@ -108,9 +107,7 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput(
"MCP 服务器 ID 不能为空".into(),
));
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
// 基础字段校验(尽量宽松)
if !spec.is_object() {
@@ -179,9 +176,7 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
pub fn delete_mcp_server(id: &str) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput(
"MCP 服务器 ID 不能为空".into(),
));
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
let path = user_config_path();
if !path.exists() {
@@ -261,15 +256,9 @@ pub fn set_mcp_servers_map(
};
if let Some(server_val) = obj.remove("server") {
let server_obj = server_val
.as_object()
.cloned()
.ok_or_else(|| {
AppError::McpValidation(format!(
"MCP 服务器 '{}' server 字段不是对象",
id
))
})?;
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
AppError::McpValidation(format!("MCP 服务器 '{}' server 字段不是对象", id))
})?;
obj = server_obj;
}

View File

@@ -11,8 +11,7 @@ fn claude_dir() -> Result<PathBuf, AppError> {
if let Some(dir) = crate::settings::get_claude_override_dir() {
return Ok(dir);
}
let home = dirs::home_dir()
.ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?;
let home = dirs::home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?;
Ok(home.join(CLAUDE_DIR))
}
@@ -81,8 +80,7 @@ pub fn write_claude_config() -> Result<bool, AppError> {
if changed || !path.exists() {
let serialized = serde_json::to_string_pretty(&obj)
.map_err(|e| AppError::JsonSerialize { source: e })?;
fs::write(&path, format!("{}\n", serialized))
.map_err(|e| AppError::io(&path, e))?;
fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?;
Ok(true)
} else {
Ok(false)
@@ -114,8 +112,8 @@ pub fn clear_claude_config() -> Result<bool, AppError> {
return Ok(false);
}
let serialized = serde_json::to_string_pretty(&value)
.map_err(|e| AppError::JsonSerialize { source: e })?;
let serialized =
serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?;
fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?;
Ok(true)
}

View File

@@ -44,7 +44,10 @@ pub fn get_codex_provider_paths(
}
/// 删除 Codex 供应商配置文件
pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), AppError> {
pub fn delete_codex_provider_config(
provider_id: &str,
provider_name: &str,
) -> Result<(), AppError> {
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
delete_file(&auth_path).ok();
@@ -56,7 +59,10 @@ pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> R
//(移除未使用的备份/保存/恢复/导入函数,避免 dead_code 告警)
/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> Result<(), AppError> {
pub fn write_codex_live_atomic(
auth: &Value,
config_text_opt: Option<&str>,
) -> Result<(), AppError> {
let auth_path = get_codex_auth_path();
let config_path = get_codex_config_path();
@@ -70,12 +76,11 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
} else {
None
};
let _old_config =
if config_path.exists() {
Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)
} else {
None
};
let _old_config = if config_path.exists() {
Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)
} else {
None
};
// 准备写入内容
let cfg_text = match config_text_opt {
@@ -83,8 +88,7 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
None => String::new(),
};
if !cfg_text.trim().is_empty() {
toml::from_str::<toml::Table>(&cfg_text)
.map_err(|e| AppError::toml(&config_path, e))?;
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?;
}
// 第一步:写 auth.json

View File

@@ -7,6 +7,7 @@ use tauri::State;
use crate::app_config::AppType;
use crate::claude_mcp;
use crate::error::AppError;
use crate::mcp;
use crate::store::AppState;
@@ -159,15 +160,8 @@ pub async fn set_mcp_enabled(
id: String,
enabled: bool,
) -> Result<bool, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let app_ty = AppType::from(app.as_deref().unwrap_or("claude"));
let changed = mcp::set_enabled_and_sync_for(&mut cfg, &app_ty, &id, enabled)?;
drop(cfg);
state.save()?;
Ok(changed)
set_mcp_enabled_internal(&*state, app_ty, &id, enabled).map_err(Into::into)
}
/// 手动同步:将启用的 MCP 投影到 ~/.claude.json
@@ -207,10 +201,40 @@ pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result<boo
/// 从 ~/.claude.json 导入 MCP 定义到 config.json
#[tauri::command]
pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result<usize, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
import_mcp_from_claude_internal(&*state).map_err(Into::into)
}
/// 从 ~/.codex/config.toml 导入 MCP 定义到 config.json
#[tauri::command]
pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result<usize, String> {
import_mcp_from_codex_internal(&*state).map_err(Into::into)
}
fn set_mcp_enabled_internal(
state: &AppState,
app_ty: AppType,
id: &str,
enabled: bool,
) -> Result<bool, AppError> {
let mut cfg = state.config.lock()?;
let changed = mcp::set_enabled_and_sync_for(&mut cfg, &app_ty, id, enabled)?;
drop(cfg);
state.save()?;
Ok(changed)
}
#[doc(hidden)]
pub fn set_mcp_enabled_test_hook(
state: &AppState,
app_ty: AppType,
id: &str,
enabled: bool,
) -> Result<bool, AppError> {
set_mcp_enabled_internal(state, app_ty, id, enabled)
}
fn import_mcp_from_claude_internal(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.lock()?;
let changed = mcp::import_from_claude(&mut cfg)?;
drop(cfg);
if changed > 0 {
@@ -219,13 +243,13 @@ pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result<usize,
Ok(changed)
}
/// 从 ~/.codex/config.toml 导入 MCP 定义到 config.json
#[tauri::command]
pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result<usize, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
#[doc(hidden)]
pub fn import_mcp_from_claude_test_hook(state: &AppState) -> Result<usize, AppError> {
import_mcp_from_claude_internal(state)
}
fn import_mcp_from_codex_internal(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.lock()?;
let changed = mcp::import_from_codex(&mut cfg)?;
drop(cfg);
if changed > 0 {
@@ -233,3 +257,8 @@ pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result<usize,
}
Ok(changed)
}
#[doc(hidden)]
pub fn import_mcp_from_codex_test_hook(state: &AppState) -> Result<usize, AppError> {
import_mcp_from_codex_internal(state)
}

View File

@@ -271,41 +271,14 @@ pub async fn delete_provider(
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let mut config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if manager.current == id {
return Err("不能删除当前正在使用的供应商".to_string());
{
let mut config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
ProviderService::delete(&mut config, app_type, &id).map_err(|e| e.to_string())?;
}
let provider = manager
.providers
.get(&id)
.ok_or_else(|| format!("供应商不存在: {}", id))?
.clone();
match app_type {
AppType::Codex => {
codex_config::delete_codex_provider_config(&id, &provider.name)?;
}
AppType::Claude => {
use crate::config::{delete_file, get_provider_config_path};
let by_name = get_provider_config_path(&id, Some(&provider.name));
let by_id = get_provider_config_path(&id, None);
delete_file(&by_name)?;
delete_file(&by_id)?;
}
}
manager.providers.remove(&id);
drop(config);
state.save()?;
Ok(true)
@@ -313,10 +286,7 @@ pub async fn delete_provider(
/// 切换供应商
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
let mut config = state
.config
.lock()
.map_err(AppError::from)?;
let mut config = state.config.lock().map_err(AppError::from)?;
ProviderService::switch(&mut config, app_type, id)?;
@@ -351,6 +321,65 @@ pub async fn switch_provider(
.map_err(|e| e.to_string())
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> {
{
let config = state.config.lock()?;
if let Some(manager) = config.get_manager(&app_type) {
if !manager.get_all_providers().is_empty() {
// 已存在供应商则视为已导入,保持与原逻辑一致
return Ok(());
}
}
}
let settings_config = match app_type {
AppType::Codex => {
let auth_path = codex_config::get_codex_auth_path();
if !auth_path.exists() {
return Err(AppError::Message("Codex 配置文件不存在".to_string()));
}
let auth: serde_json::Value = crate::config::read_json_file(&auth_path)?;
let config_str = crate::codex_config::read_and_validate_codex_config_text()?;
serde_json::json!({ "auth": auth, "config": config_str })
}
AppType::Claude => {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
return Err(AppError::Message("Claude Code 配置文件不存在".to_string()));
}
crate::config::read_json_file(&settings_path)?
}
};
let provider = Provider::with_id(
"default".to_string(),
"default".to_string(),
settings_config,
None,
);
let mut config = state.config.lock()?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?;
manager.providers.insert(provider.id.clone(), provider);
manager.current = "default".to_string();
drop(config);
state.save()?;
Ok(())
}
#[doc(hidden)]
pub fn import_default_config_test_hook(
state: &AppState,
app_type: AppType,
) -> Result<(), AppError> {
import_default_config_internal(state, app_type)
}
/// 导入当前配置为默认供应商
#[tauri::command]
pub async fn import_default_config(
@@ -364,62 +393,9 @@ pub async fn import_default_config(
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
{
let config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
if let Some(manager) = config.get_manager(&app_type) {
if !manager.get_all_providers().is_empty() {
return Ok(true);
}
}
}
let settings_config = match app_type {
AppType::Codex => {
let auth_path = codex_config::get_codex_auth_path();
if !auth_path.exists() {
return Err("Codex 配置文件不存在".to_string());
}
let auth: serde_json::Value =
crate::config::read_json_file::<serde_json::Value>(&auth_path)?;
let config_str = crate::codex_config::read_and_validate_codex_config_text()?;
serde_json::json!({ "auth": auth, "config": config_str })
}
AppType::Claude => {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
return Err("Claude Code 配置文件不存在".to_string());
}
crate::config::read_json_file::<serde_json::Value>(&settings_path)?
}
};
let provider = Provider::with_id(
"default".to_string(),
"default".to_string(),
settings_config,
None,
);
let mut config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.providers.insert(provider.id.clone(), provider);
manager.current = "default".to_string();
drop(config);
state.save()?;
Ok(true)
import_default_config_internal(&*state, app_type)
.map(|_| true)
.map_err(Into::into)
}
/// 查询供应商用量

View File

@@ -11,8 +11,7 @@ pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
/// 保存设置
#[tauri::command]
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
crate::settings::update_settings(settings)
.map_err(|e| e.to_string())?;
crate::settings::update_settings(settings).map_err(|e| e.to_string())?;
Ok(true)
}

View File

@@ -150,10 +150,7 @@ pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>)
/// 读取 JSON 配置文件
pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, AppError> {
if !path.exists() {
return Err(AppError::Config(format!(
"文件不存在: {}",
path.display()
)));
return Err(AppError::Config(format!("文件不存在: {}", path.display())));
}
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
@@ -168,8 +165,8 @@ pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppErr
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let json = serde_json::to_string_pretty(data)
.map_err(|e| AppError::JsonSerialize { source: e })?;
let json =
serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;
atomic_write(path, json.as_bytes())
}
@@ -204,8 +201,7 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
tmp.push(format!("{}.tmp.{}", file_name, ts));
{
let mut f =
fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
f.flush().map_err(|e| AppError::io(&tmp, e))?;
}
@@ -226,11 +222,7 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
let _ = fs::remove_file(path);
}
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
context: format!(
"原子替换失败: {} -> {}",
tmp.display(),
path.display()
),
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
source: e,
})?;
}
@@ -238,11 +230,7 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
#[cfg(not(windows))]
{
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
context: format!(
"原子替换失败: {} -> {}",
tmp.display(),
path.display()
),
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
source: e,
})?;
}
@@ -287,11 +275,7 @@ mod tests {
/// 复制文件
pub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> {
fs::copy(from, to).map_err(|e| AppError::IoContext {
context: format!(
"复制文件失败 ({} -> {})",
from.display(),
to.display()
),
context: format!("复制文件失败 ({} -> {})", from.display(), to.display()),
source: e,
})?;
Ok(())

View File

@@ -131,23 +131,15 @@ fn sync_codex_live(
) -> Result<(), AppError> {
use serde_json::Value;
let settings = provider
.settings_config
.as_object()
.ok_or_else(|| {
AppError::Config(format!(
"供应商 {} 的 Codex 配置必须是对象",
provider_id
))
})?;
let auth = settings
.get("auth")
.ok_or_else(|| {
AppError::Config(format!(
"供应商 {} 的 Codex 配置缺少 auth 字段",
provider_id
))
})?;
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::Config(format!("供应商 {} 的 Codex 配置必须是对象", provider_id))
})?;
let auth = settings.get("auth").ok_or_else(|| {
AppError::Config(format!(
"供应商 {} 的 Codex 配置缺少 auth 字段",
provider_id
))
})?;
if !auth.is_object() {
return Err(AppError::Config(format!(
"供应商 {} 的 Codex auth 配置必须是 JSON 对象",
@@ -203,12 +195,11 @@ fn sync_claude_live(
pub async fn export_config_to_file(file_path: String) -> Result<Value, String> {
// 读取当前配置文件
let config_path = crate::config::get_app_config_path();
let config_content = fs::read_to_string(&config_path)
.map_err(|e| AppError::io(&config_path, e).to_string())?;
let config_content =
fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e).to_string())?;
// 写入到指定文件
fs::write(&file_path, &config_content)
.map_err(|e| AppError::io(&file_path, e).to_string())?;
fs::write(&file_path, &config_content).map_err(|e| AppError::io(&file_path, e).to_string())?;
Ok(json!({
"success": true,
@@ -239,18 +230,15 @@ 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 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))?;
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))?;
fs::write(&config_path, &import_content).map_err(|e| AppError::io(&config_path, e))?;
{
let mut guard = state.config.lock().map_err(AppError::from)?;

View File

@@ -10,25 +10,27 @@ mod import_export;
mod mcp;
mod migration;
mod provider;
mod services;
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 commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use error::AppError;
pub use import_export::{
create_backup, export_config_to_file, import_config_from_path, sync_current_providers_to_live,
};
pub use mcp::{
import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex,
};
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::*;
pub use settings::{update_settings, AppSettings};
pub use store::AppState;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
@@ -43,10 +45,7 @@ fn create_tray_menu(
app: &tauri::AppHandle,
app_state: &AppState,
) -> Result<Menu<tauri::Wry>, AppError> {
let config = app_state
.config
.lock()
.map_err(AppError::from)?;
let config = app_state.config.lock().map_err(AppError::from)?;
let mut menu_builder = MenuBuilder::new(app);

View File

@@ -44,15 +44,11 @@ fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
let obj = entry
.as_object()
.ok_or_else(|| {
AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into())
})?;
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
let server = obj
.get("server")
.ok_or_else(|| {
AppError::McpValidation("MCP 服务器条目缺少 server 字段".into())
})?;
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
validate_server_spec(server)?;
for key in ["name", "description", "homepage", "docs"] {
@@ -67,9 +63,9 @@ fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
}
if let Some(tags) = obj.get("tags") {
let arr = tags.as_array().ok_or_else(|| {
AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into())
})?;
let arr = tags
.as_array()
.ok_or_else(|| AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into()))?;
if !arr.iter().all(|item| item.is_string()) {
return Err(AppError::McpValidation(
"MCP 服务器 tags 必须为字符串数组".into(),
@@ -182,14 +178,10 @@ pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usiz
fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {
let obj = entry
.as_object()
.ok_or_else(|| {
AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into())
})?;
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
let server = obj
.get("server")
.ok_or_else(|| {
AppError::McpValidation("MCP 服务器条目缺少 server 字段".into())
})?;
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
if !server.is_object() {
return Err(AppError::McpValidation(
@@ -255,9 +247,7 @@ pub fn upsert_in_config_for(
spec: Value,
) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput(
"MCP 服务器 ID 不能为空".into(),
));
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
normalize_servers_for(config, app);
validate_mcp_entry(&spec)?;
@@ -265,14 +255,10 @@ pub fn upsert_in_config_for(
let mut entry_obj = spec
.as_object()
.cloned()
.ok_or_else(|| {
AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into())
})?;
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
if let Some(existing_id) = entry_obj.get("id") {
let Some(existing_id_str) = existing_id.as_str() else {
return Err(AppError::McpValidation(
"MCP 服务器 id 必须为字符串".into(),
));
return Err(AppError::McpValidation("MCP 服务器 id 必须为字符串".into()));
};
if existing_id_str != id {
return Err(AppError::McpValidation(format!(
@@ -299,9 +285,7 @@ pub fn delete_in_config_for(
id: &str,
) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput(
"MCP 服务器 ID 不能为空".into(),
));
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
normalize_servers_for(config, app);
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
@@ -316,9 +300,7 @@ pub fn set_enabled_and_sync_for(
enabled: bool,
) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput(
"MCP 服务器 ID 不能为空".into(),
));
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
normalize_servers_for(config, app);
if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
@@ -326,9 +308,7 @@ pub fn set_enabled_and_sync_for(
let mut obj = spec
.as_object()
.cloned()
.ok_or_else(|| {
AppError::McpValidation("MCP 服务器定义必须为 JSON 对象".into())
})?;
.ok_or_else(|| AppError::McpValidation("MCP 服务器定义必须为 JSON 对象".into()))?;
obj.insert("enabled".into(), json!(enabled));
*spec = Value::Object(obj);
} else {
@@ -362,9 +342,8 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
let text_opt = crate::claude_mcp::read_mcp_json()?;
let Some(text) = text_opt else { return Ok(0) };
let mut changed = normalize_servers_for(config, &AppType::Claude);
let v: Value = serde_json::from_str(&text).map_err(|e| {
AppError::McpValidation(format!("解析 ~/.claude.json 失败: {}", e))
})?;
let v: Value = serde_json::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {}", e)))?;
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
return Ok(changed);
};
@@ -440,12 +419,8 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
}
let mut changed_total = normalize_servers_for(config, &AppType::Codex);
let root: toml::Table = toml::from_str(&text).map_err(|e| {
AppError::McpValidation(format!(
"解析 ~/.codex/config.toml 失败: {}",
e
))
})?;
let root: toml::Table = toml::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {}", e)))?;
// helper处理一组 servers 表
let mut import_servers_tbl = |servers_tbl: &toml::value::Table| {
@@ -619,9 +594,8 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
let mut root: TomlTable = if base_text.trim().is_empty() {
TomlTable::new()
} else {
toml::from_str::<TomlTable>(&base_text).map_err(|e| {
AppError::McpValidation(format!("解析 config.toml 失败: {}", e))
})?
toml::from_str::<TomlTable>(&base_text)
.map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {}", e)))?
};
// 3) 写入 servers 表(支持 mcp.servers 与 mcp_servers优先沿用已有风格默认 mcp_servers
@@ -767,9 +741,8 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
}
// 4) 序列化并写回 config.toml仅改 TOML不触碰 auth.json
let new_text = toml::to_string(&TomlValue::Table(root)).map_err(|e| {
AppError::McpValidation(format!("序列化 config.toml 失败: {}", e))
})?;
let new_text = toml::to_string(&TomlValue::Table(root))
.map_err(|e| AppError::McpValidation(format!("序列化 config.toml 失败: {}", e)))?;
let path = crate::codex_config::get_codex_config_path();
crate::config::write_text_file(&path, &new_text)?;

View File

@@ -149,8 +149,7 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, A
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
let marker = get_marker_path();
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| AppError::io(parent, e))?;
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
if marker.exists() {
return Ok(false);

View File

@@ -1,8 +1,11 @@
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::config::{
delete_file, get_claude_settings_path, get_provider_config_path, read_json_file,
write_json_file,
};
use crate::error::AppError;
use crate::mcp;
@@ -46,10 +49,7 @@ impl ProviderService {
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),
);
obj.insert("config".to_string(), Value::String(cfg_text_after));
}
}
}
@@ -102,9 +102,9 @@ impl ProviderService {
.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))
})?;
let auth = settings
.get("auth")
.ok_or_else(|| AppError::Config(format!("供应商 {} 缺少 auth 配置", provider.id)))?;
if !auth.is_object() {
return Err(AppError::Config(format!(
"供应商 {} 的 auth 必须是对象",
@@ -181,4 +181,44 @@ impl ProviderService {
write_json_file(&settings_path, &provider.settings_config)?;
Ok(())
}
pub fn delete(
config: &mut MultiAppConfig,
app_type: AppType,
provider_id: &str,
) -> Result<(), AppError> {
let current_matches = config
.get_manager(&app_type)
.map(|m| m.current == provider_id)
.unwrap_or(false);
if current_matches {
return Err(AppError::Config("不能删除当前正在使用的供应商".into()));
}
let provider = config
.get_manager(&app_type)
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?
.providers
.get(provider_id)
.cloned()
.ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))?;
match app_type {
AppType::Codex => {
crate::codex_config::delete_codex_provider_config(provider_id, &provider.name)?;
}
AppType::Claude => {
let by_name = get_provider_config_path(provider_id, Some(&provider.name));
let by_id = get_provider_config_path(provider_id, None);
delete_file(&by_name)?;
delete_file(&by_id)?;
}
}
if let Some(manager) = config.get_manager_mut(&app_type) {
manager.providers.remove(provider_id);
}
Ok(())
}
}

View File

@@ -1,5 +1,5 @@
use reqwest::Client;
use rquickjs::{Context, Runtime, Function};
use rquickjs::{Context, Function, Runtime};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
@@ -20,8 +20,8 @@ pub async fn execute_usage_script(
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
let request_config = {
let runtime = Runtime::new()
.map_err(|e| AppError::Message(format!("创建 JS 运行时失败: {}", e)))?;
let runtime =
Runtime::new().map_err(|e| AppError::Message(format!("创建 JS 运行时失败: {}", e)))?;
let context = Context::full(&runtime)
.map_err(|e| AppError::Message(format!("创建 JS 上下文失败: {}", e)))?;
@@ -57,8 +57,8 @@ pub async fn execute_usage_script(
// 5. 在独立作用域中执行 extractor确保 Runtime/Context 在函数结束前释放)
let result: Value = {
let runtime = Runtime::new()
.map_err(|e| AppError::Message(format!("创建 JS 运行时失败: {}", e)))?;
let runtime =
Runtime::new().map_err(|e| AppError::Message(format!("创建 JS 运行时失败: {}", e)))?;
let context = Context::full(&runtime)
.map_err(|e| AppError::Message(format!("创建 JS 上下文失败: {}", e)))?;
@@ -121,10 +121,7 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
.build()
.map_err(|e| AppError::Message(format!("创建客户端失败: {}", e)))?;
let method = config
.method
.parse()
.unwrap_or(reqwest::Method::GET);
let method = config.method.parse().unwrap_or(reqwest::Method::GET);
let mut req = client.request(method.clone(), &config.url);
@@ -171,9 +168,7 @@ fn validate_result(result: &Value) -> Result<(), AppError> {
}
for (idx, item) in arr.iter().enumerate() {
validate_single_usage(item)
.map_err(|e| {
AppError::InvalidInput(format!("数组索引[{}]验证失败: {}", idx, e))
})?;
.map_err(|e| AppError::InvalidInput(format!("数组索引[{}]验证失败: {}", idx, e)))?;
}
return Ok(());
}
@@ -184,18 +179,16 @@ fn validate_result(result: &Value) -> Result<(), AppError> {
/// 验证单个用量数据对象
fn validate_single_usage(result: &Value) -> Result<(), AppError> {
let obj = result.as_object().ok_or_else(|| {
AppError::InvalidInput("脚本必须返回对象或对象数组".into())
})?;
let obj = result
.as_object()
.ok_or_else(|| AppError::InvalidInput("脚本必须返回对象或对象数组".into()))?;
// 所有字段均为可选,只进行类型检查
if obj.contains_key("isValid")
&& !result["isValid"].is_null()
&& !result["isValid"].is_boolean()
{
return Err(AppError::InvalidInput(
"isValid 必须是布尔值或 null".into(),
));
return Err(AppError::InvalidInput("isValid 必须是布尔值或 null".into()));
}
if obj.contains_key("invalidMessage")
&& !result["invalidMessage"].is_null()
@@ -209,33 +202,16 @@ fn validate_single_usage(result: &Value) -> Result<(), AppError> {
&& !result["remaining"].is_null()
&& !result["remaining"].is_number()
{
return Err(AppError::InvalidInput(
"remaining 必须是数字或 null".into(),
));
return Err(AppError::InvalidInput("remaining 必须是数字或 null".into()));
}
if obj.contains_key("unit")
&& !result["unit"].is_null()
&& !result["unit"].is_string()
{
return Err(AppError::InvalidInput(
"unit 必须是字符串或 null".into(),
));
if obj.contains_key("unit") && !result["unit"].is_null() && !result["unit"].is_string() {
return Err(AppError::InvalidInput("unit 必须是字符串或 null".into()));
}
if obj.contains_key("total")
&& !result["total"].is_null()
&& !result["total"].is_number()
{
return Err(AppError::InvalidInput(
"total 必须是数字或 null".into(),
));
if obj.contains_key("total") && !result["total"].is_null() && !result["total"].is_number() {
return Err(AppError::InvalidInput("total 必须是数字或 null".into()));
}
if obj.contains_key("used")
&& !result["used"].is_null()
&& !result["used"].is_number()
{
return Err(AppError::InvalidInput(
"used 必须是数字或 null".into(),
));
if obj.contains_key("used") && !result["used"].is_null() && !result["used"].is_number() {
return Err(AppError::InvalidInput("used 必须是数字或 null".into()));
}
if obj.contains_key("planName")
&& !result["planName"].is_null()
@@ -245,13 +221,8 @@ fn validate_single_usage(result: &Value) -> Result<(), AppError> {
"planName 必须是字符串或 null".into(),
));
}
if obj.contains_key("extra")
&& !result["extra"].is_null()
&& !result["extra"].is_string()
{
return Err(AppError::InvalidInput(
"extra 必须是字符串或 null".into(),
));
if obj.contains_key("extra") && !result["extra"].is_null() && !result["extra"].is_string() {
return Err(AppError::InvalidInput("extra 必须是字符串或 null".into()));
}
Ok(())