diff --git a/docs/BACKEND_REFACTOR_PLAN.md b/docs/BACKEND_REFACTOR_PLAN.md new file mode 100644 index 0000000..be66491 --- /dev/null +++ b/docs/BACKEND_REFACTOR_PLAN.md @@ -0,0 +1,139 @@ +# CC Switch Rust 后端重构方案 + +## 目录 +- [背景与现状](#背景与现状) +- [问题确认](#问题确认) +- [方案评估](#方案评估) +- [渐进式重构路线](#渐进式重构路线) +- [测试策略](#测试策略) +- [风险与对策](#风险与对策) +- [总结](#总结) + +## 背景与现状 +- 前端已完成重构,后端 (Tauri + Rust) 仍维持历史结构。 +- 核心文件集中在 `src-tauri/src/commands.rs`、`lib.rs` 等超大文件中,业务逻辑与界面事件耦合严重。 +- 测试覆盖率低,只有零散单元测试,缺乏集成验证。 + +## 问题确认 + +| 提案问题 | 实际情况 | 严重程度 | +| --- | --- | --- | +| `commands.rs` 过长 | ✅ 1526 行,包含 32 个命令,职责混杂 | 🔴 高 | +| `lib.rs` 缺少服务层 | ✅ 541 行,托盘/事件/业务逻辑耦合 | 🟡 中 | +| `Result` 泛滥 | ✅ 118 处,错误上下文丢失 | 🟡 中 | +| 全局 `Mutex` 阻塞 | ✅ 31 处 `.lock()` 调用,读写不分离 | 🟡 中 | +| 配置逻辑分散 | ✅ 分布在 5 个文件 (`config`/`app_config`/`app_store`/`settings`/`codex_config`) | 🟢 低 | + +代码规模分布(约 5.4k SLOC): +- `commands.rs`: 1526 行(28%)→ 第一优先级 🎯 +- `lib.rs`: 541 行(10%)→ 托盘逻辑与业务耦合 +- `mcp.rs`: 732 行(14%)→ 相对清晰 +- `migration.rs`: 431 行(8%)→ 一次性逻辑 +- 其他文件合计:2156 行(40%) + +## 方案评估 + +### ✅ 优点 +1. **分层架构清晰** + - `commands/`:Tauri 命令薄层 + - `services/`:业务流程,如供应商切换、MCP 同步 + - `infrastructure/`:配置读写、外设交互 + - `domain/`:数据模型 (`Provider`, `AppType` 等) + → 提升可测试性、降低耦合度、方便团队协作。 + +2. **统一错误处理** + - 引入 `AppError`(`thiserror`),保留错误链和上下文。 + - Tauri 命令仍返回 `Result`,通过 `From` 自动转换。 + - 改善日志可读性,利于排查。 + +3. **并发优化** + - `AppState` 切换为 `RwLock`。 + - 读多写少的场景提升吞吐(如频繁查询供应商列表)。 + +### ⚠️ 风险 +1. **过度设计** + - 完整 DDD 四层在 5k 行项目中会增加 30-50% 维护成本。 + - Rust trait + repository 样板较多,收益不足。 + - 推荐“轻量分层”而非正统 DDD。 + +2. **迁移成本高** + - `commands.rs` 拆分、错误统一、锁改造触及多文件。 + - 测试缺失导致重构风险高,需先补测试。 + - 估算完整改造需 5-6 周;建议分阶段输出可落地价值。 + +3. **技术选型需谨慎** + - `parking_lot` 相比标准库 `RwLock` 提升有限,不必引入。 + - `spawn_blocking` 仅用于 >100ms 的阻塞任务,避免滥用。 + - 以现有依赖为主,控制复杂度。 + +## 渐进式重构路线 + +### 阶段 1:统一错误处理(高收益 / 低风险) +- 新增 `src-tauri/src/error.rs`,定义 `AppError`。 +- 底层文件 IO、配置解析等函数返回 `Result`。 +- 命令层通过 `?` 自动传播,最终 `.map_err(Into::into)`。 +- 预估 3-5 天,立即启动。 + +### 阶段 2:拆分 `commands.rs`(高收益 / 中风险) +- 按业务拆分为 `commands/provider.rs`、`commands/mcp.rs`、`commands/config.rs`、`commands/settings.rs`、`commands/misc.rs`。 +- `commands/mod.rs` 统一导出和注册。 +- 文件行数降低到 200-300 行/文件,职责单一。 +- 预估 5-7 天,可并行进行部分重构。 + +### 阶段 3:补充测试(中收益 / 中风险) +- 引入 `tests/` 或 `src-tauri/tests/` 集成测试,覆盖供应商切换、MCP 同步、配置迁移。 +- 使用 `tempfile`/`tempdir` 隔离文件系统,组合少量回归脚本。 +- 预估 5-7 天,为后续重构提供安全网。 + +### 阶段 4:提取轻量服务层(中收益 / 中风险) +- 新增 `services/provider_service.rs`、`services/mcp_service.rs`。 +- 不强制使用 trait;直接以自由函数/结构体实现业务流程。 + ```rust + pub struct ProviderService; + impl ProviderService { + pub fn switch(config: &mut MultiAppConfig, app: AppType, id: &str) -> Result<(), AppError> { + // 业务流程:验证、回填、落盘、更新 current、触发事件 + } + } + ``` +- 命令层负责参数解析,服务层处理业务逻辑,托盘逻辑重用同一接口。 +- 预估 7-10 天,可在测试补齐后执行。 + +### 阶段 5:锁与阻塞优化(低收益 / 低风险) +- `AppState` 从 `Mutex` 改为 `RwLock`。 +- 读写操作分别使用 `read()`/`write()`,减少不必要的互斥。 +- 长耗时任务(如归档、批量迁移)用 `spawn_blocking` 包裹,其余直接同步调用。 +- 预估 3-5 天,可在主流程稳定后安排。 + +## 测试策略 +- **优先覆盖场景** + - 供应商切换:状态更新 + live 配置同步 + - MCP 同步:enabled 服务器快照与落盘 + - 配置迁移:归档、备份与版本升级 +- **推荐结构** + ```rust + #[cfg(test)] + mod integration { + use super::*; + #[test] + fn switch_provider_updates_live_config() { /* ... */ } + #[test] + fn sync_mcp_to_codex_updates_claude_config() { /* ... */ } + #[test] + fn migration_preserves_backup() { /* ... */ } + } + ``` +- 目标覆盖率:关键路径 >80%,文件 IO/迁移 >70%。 + +## 风险与对策 +- **测试不足** → 阶段 3 强制补齐,建立基础集成测试。 +- **重构跨度大** → 按阶段在独立分支推进(如 `refactor/backend-step1` 等)。 +- **回滚困难** → 每阶段结束打 tag(如 `v3.6.0-backend-step1`),保留回滚点。 +- **功能回归** → 重构后执行手动冒烟流程:供应商切换、托盘操作、MCP 同步、配置导入导出。 + +## 总结 +- 当前规模下不建议整体引入完整 DDD/四层架构,避免过度设计。 +- 建议遵循“错误统一 → 命令拆分 → 补测试 → 服务层抽象 → 锁优化”的渐进式策略。 +- 完成阶段 1-3 后即可显著提升可维护性与可靠性;阶段 4-5 可根据资源灵活安排。 +- 重构过程中同步维护文档与测试,确保团队成员对架构演进保持一致认知。 + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b1f90cf..ca88e5a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -585,6 +585,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-store", "tauri-plugin-updater", + "thiserror 1.0.69", "tokio", "toml 0.8.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 47333a9..df47eda 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,7 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } futures = "0.3" regex = "1.10" rquickjs = { version = "0.8", features = ["array-buffer", "classes"] } +thiserror = "1.0" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index c46f910..3393a0d 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -19,6 +19,7 @@ pub struct McpRoot { } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; +use crate::error::AppError; use crate::provider::ProviderManager; /// 应用类型 @@ -80,7 +81,7 @@ impl Default for MultiAppConfig { impl MultiAppConfig { /// 从文件加载配置(处理v1到v2的迁移) - pub fn load() -> Result { + pub fn load() -> Result { let config_path = get_app_config_path(); if !config_path.exists() { @@ -90,7 +91,7 @@ impl MultiAppConfig { // 尝试读取文件 let content = std::fs::read_to_string(&config_path) - .map_err(|e| format!("读取配置文件失败: {}", e))?; + .map_err(|e| AppError::io(&config_path, e))?; // 检查是否是旧版本格式(v1) if let Ok(v1_config) = serde_json::from_str::(&content) { @@ -130,11 +131,11 @@ impl MultiAppConfig { } // 尝试读取v2格式 - serde_json::from_str::(&content).map_err(|e| format!("解析配置文件失败: {}", e)) + serde_json::from_str::(&content).map_err(|e| AppError::json(&config_path, e)) } /// 保存配置到文件 - pub fn save(&self) -> Result<(), String> { + pub fn save(&self) -> Result<(), AppError> { let config_path = get_app_config_path(); // 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak,再写入新内容 if config_path.exists() { diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index 7bccb7e..6eff697 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -5,6 +5,7 @@ use std::fs; use std::path::{Path, PathBuf}; use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path}; +use crate::error::AppError; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -60,28 +61,26 @@ fn ensure_mcp_override_migrated() { } } -fn read_json_value(path: &Path) -> Result { +fn read_json_value(path: &Path) -> Result { if !path.exists() { return Ok(serde_json::json!({})); } - let content = - fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?; - let value: Value = serde_json::from_str(&content) - .map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))?; + 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))?; Ok(value) } -fn write_json_value(path: &Path, value: &Value) -> Result<(), String> { +fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?; + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } let json = - serde_json::to_string_pretty(value).map_err(|e| format!("序列化 JSON 失败: {}", e))?; + serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?; atomic_write(path, json.as_bytes()) } -pub fn get_mcp_status() -> Result { +pub fn get_mcp_status() -> Result { let path = user_config_path(); let (exists, count) = if path.exists() { let v = read_json_value(&path)?; @@ -98,35 +97,43 @@ pub fn get_mcp_status() -> Result { }) } -pub fn read_mcp_json() -> Result, String> { +pub fn read_mcp_json() -> Result, AppError> { let path = user_config_path(); if !path.exists() { return Ok(None); } - let content = fs::read_to_string(&path).map_err(|e| format!("读取 MCP 配置失败: {}", e))?; + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; Ok(Some(content)) } -pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { +pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { if id.trim().is_empty() { - return Err("MCP 服务器 ID 不能为空".into()); + return Err(AppError::InvalidInput( + "MCP 服务器 ID 不能为空".into(), + )); } // 基础字段校验(尽量宽松) if !spec.is_object() { - return Err("MCP 服务器定义必须为 JSON 对象".into()); + return Err(AppError::McpValidation( + "MCP 服务器定义必须为 JSON 对象".into(), + )); } let t_opt = spec.get("type").and_then(|x| x.as_str()); let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理) let is_http = t_opt.map(|t| t == "http").unwrap_or(false); if !(is_stdio || is_http) { - return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into()); + return Err(AppError::McpValidation( + "MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into(), + )); } // stdio 类型必须有 command if is_stdio { let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or(""); if cmd.is_empty() { - return Err("stdio 类型的 MCP 服务器缺少 command 字段".into()); + return Err(AppError::McpValidation( + "stdio 类型的 MCP 服务器缺少 command 字段".into(), + )); } } @@ -134,7 +141,9 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { if is_http { let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); if url.is_empty() { - return Err("http 类型的 MCP 服务器缺少 url 字段".into()); + return Err(AppError::McpValidation( + "http 类型的 MCP 服务器缺少 url 字段".into(), + )); } } @@ -149,7 +158,7 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { { let obj = root .as_object_mut() - .ok_or_else(|| "mcp.json 根必须是对象".to_string())?; + .ok_or_else(|| AppError::Config("mcp.json 根必须是对象".into()))?; if !obj.contains_key("mcpServers") { obj.insert("mcpServers".into(), serde_json::json!({})); } @@ -168,9 +177,11 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { Ok(true) } -pub fn delete_mcp_server(id: &str) -> Result { +pub fn delete_mcp_server(id: &str) -> Result { if id.trim().is_empty() { - return Err("MCP 服务器 ID 不能为空".into()); + return Err(AppError::InvalidInput( + "MCP 服务器 ID 不能为空".into(), + )); } let path = user_config_path(); if !path.exists() { @@ -188,7 +199,7 @@ pub fn delete_mcp_server(id: &str) -> Result { Ok(true) } -pub fn validate_command_in_path(cmd: &str) -> Result { +pub fn validate_command_in_path(cmd: &str) -> Result { if cmd.trim().is_empty() { return Ok(false); } @@ -229,7 +240,7 @@ pub fn validate_command_in_path(cmd: &str) -> Result { /// 仅覆盖 mcpServers,其他字段保持不变 pub fn set_mcp_servers_map( servers: &std::collections::HashMap, -) -> Result<(), String> { +) -> Result<(), AppError> { let path = user_config_path(); let mut root = if path.exists() { read_json_value(&path)? @@ -243,14 +254,22 @@ pub fn set_mcp_servers_map( let mut obj = if let Some(map) = spec.as_object() { map.clone() } else { - return Err(format!("MCP 服务器 '{}' 不是对象", id)); + return Err(AppError::McpValidation(format!( + "MCP 服务器 '{}' 不是对象", + id + ))); }; if let Some(server_val) = obj.remove("server") { let server_obj = server_val .as_object() .cloned() - .ok_or_else(|| format!("MCP 服务器 '{}' server 字段不是对象", id))?; + .ok_or_else(|| { + AppError::McpValidation(format!( + "MCP 服务器 '{}' server 字段不是对象", + id + )) + })?; obj = server_obj; } @@ -269,7 +288,7 @@ pub fn set_mcp_servers_map( { let obj = root .as_object_mut() - .ok_or_else(|| "~/.claude.json 根必须是对象".to_string())?; + .ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?; obj.insert("mcpServers".into(), Value::Object(out)); } diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 24c3004..c1ab9b3 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::config::{ atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file, }; +use crate::error::AppError; use serde_json::Value; use std::fs; use std::path::Path; @@ -43,7 +44,7 @@ pub fn get_codex_provider_paths( } /// 删除 Codex 供应商配置文件 -pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> { +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(); @@ -55,29 +56,23 @@ 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<(), String> { +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(); if let Some(parent) = auth_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("创建 Codex 目录失败: {}: {}", parent.display(), e))?; + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } // 读取旧内容用于回滚 let old_auth = if auth_path.exists() { - Some( - fs::read(&auth_path) - .map_err(|e| format!("读取旧 auth.json 失败: {}: {}", auth_path.display(), e))?, - ) + Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?) } else { None }; let _old_config = if config_path.exists() { - Some(fs::read(&config_path).map_err(|e| { - format!("读取旧 config.toml 失败: {}: {}", config_path.display(), e) - })?) + Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?) } else { None }; @@ -88,13 +83,8 @@ 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::(&cfg_text).map_err(|e| { - format!( - "config.toml 语法错误: {} (路径: {})", - e, - config_path.display() - ) - })?; + toml::from_str::(&cfg_text) + .map_err(|e| AppError::toml(&config_path, e))?; } // 第一步:写 auth.json @@ -115,43 +105,43 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R } /// 读取 `~/.codex/config.toml`,若不存在返回空字符串 -pub fn read_codex_config_text() -> Result { +pub fn read_codex_config_text() -> Result { let path = get_codex_config_path(); if path.exists() { - std::fs::read_to_string(&path).map_err(|e| format!("读取 config.toml 失败: {}", e)) + std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e)) } else { Ok(String::new()) } } /// 从给定路径读取 config.toml 文本(路径存在时);路径不存在则返回空字符串 -pub fn read_config_text_from_path(path: &Path) -> Result { +pub fn read_config_text_from_path(path: &Path) -> Result { if path.exists() { - std::fs::read_to_string(path).map_err(|e| format!("读取 {} 失败: {}", path.display(), e)) + std::fs::read_to_string(path).map_err(|e| AppError::io(path, e)) } else { Ok(String::new()) } } /// 对非空的 TOML 文本进行语法校验 -pub fn validate_config_toml(text: &str) -> Result<(), String> { +pub fn validate_config_toml(text: &str) -> Result<(), AppError> { if text.trim().is_empty() { return Ok(()); } toml::from_str::(text) .map(|_| ()) - .map_err(|e| format!("config.toml 语法错误: {}", e)) + .map_err(|e| AppError::toml(Path::new("config.toml"), e)) } /// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空) -pub fn read_and_validate_codex_config_text() -> Result { +pub fn read_and_validate_codex_config_text() -> Result { let s = read_codex_config_text()?; validate_config_toml(&s)?; Ok(s) } /// 从指定路径读取并校验 config.toml,返回文本(可能为空) -pub fn read_and_validate_config_from_path(path: &Path) -> Result { +pub fn read_and_validate_config_from_path(path: &Path) -> Result { let s = read_config_text_from_path(path)?; validate_config_toml(&s)?; Ok(s) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c294217..fa1a8c3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -546,10 +546,7 @@ pub async fn import_default_config( } let auth: serde_json::Value = crate::config::read_json_file::(&auth_path)?; - let config_str = match crate::codex_config::read_and_validate_codex_config_text() { - Ok(s) => s, - Err(e) => return Err(e), - }; + let config_str = crate::codex_config::read_and_validate_codex_config_text()?; serde_json::json!({ "auth": auth, "config": config_str }) } AppType::Claude => { @@ -770,31 +767,31 @@ pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result Result { - claude_mcp::get_mcp_status() + claude_mcp::get_mcp_status().map_err(Into::into) } /// 读取 mcp.json 文本内容(不存在则返回 Ok(None)) #[tauri::command] pub async fn read_claude_mcp_config() -> Result, String> { - claude_mcp::read_mcp_json() + claude_mcp::read_mcp_json().map_err(Into::into) } /// 新增或更新一个 MCP 服务器条目 #[tauri::command] pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result { - claude_mcp::upsert_mcp_server(&id, spec) + claude_mcp::upsert_mcp_server(&id, spec).map_err(Into::into) } /// 删除一个 MCP 服务器条目 #[tauri::command] pub async fn delete_claude_mcp_server(id: String) -> Result { - claude_mcp::delete_mcp_server(&id) + claude_mcp::delete_mcp_server(&id).map_err(Into::into) } /// 校验命令是否在 PATH 中可用(不执行) #[tauri::command] pub async fn validate_mcp_command(cmd: String) -> Result { - claude_mcp::validate_command_in_path(&cmd) + claude_mcp::validate_command_in_path(&cmd).map_err(Into::into) } // ===================== diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 554f170..e8ce235 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,9 +1,10 @@ use serde::{Deserialize, Serialize}; -// unused import removed use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use crate::error::AppError; + /// 获取 Claude Code 配置目录路径 pub fn get_claude_config_dir() -> PathBuf { if let Some(custom) = crate::settings::get_claude_override_dir() { @@ -106,14 +107,14 @@ fn ensure_unique_path(dest: PathBuf) -> PathBuf { } /// 将现有文件归档到 `~/.cc-switch/archive///` 下,返回归档路径 -pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result, String> { +pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result, AppError> { if !src.exists() { return Ok(None); } let mut dest_dir = get_archive_root(); dest_dir.push(ts.to_string()); dest_dir.push(category); - fs::create_dir_all(&dest_dir).map_err(|e| format!("创建归档目录失败: {}", e))?; + fs::create_dir_all(&dest_dir).map_err(|e| AppError::io(&dest_dir, e))?; let file_name = src .file_name() @@ -147,52 +148,53 @@ pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) } /// 读取 JSON 配置文件 -pub fn read_json_file Deserialize<'a>>(path: &Path) -> Result { +pub fn read_json_file Deserialize<'a>>(path: &Path) -> Result { if !path.exists() { - return Err(format!("文件不存在: {}", path.display())); + return Err(AppError::Config(format!( + "文件不存在: {}", + path.display() + ))); } - let content = - fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?; + let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?; - serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e)) + serde_json::from_str(&content).map_err(|e| AppError::json(path, e)) } /// 写入 JSON 配置文件 -pub fn write_json_file(path: &Path, data: &T) -> Result<(), String> { +pub fn write_json_file(path: &Path, data: &T) -> Result<(), AppError> { // 确保目录存在 if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?; + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } - let json = - serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?; + let json = serde_json::to_string_pretty(data) + .map_err(|e| AppError::JsonSerialize { source: e })?; atomic_write(path, json.as_bytes()) } /// 原子写入文本文件(用于 TOML/纯文本) -pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> { +pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?; + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } atomic_write(path, data.as_bytes()) } /// 原子写入:写入临时文件后 rename 替换,避免半写状态 -pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> { +pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?; + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } - let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?; + let parent = path + .parent() + .ok_or_else(|| AppError::Config("无效的路径".to_string()))?; let mut tmp = parent.to_path_buf(); let file_name = path .file_name() - .ok_or_else(|| "无效的文件名".to_string())? + .ok_or_else(|| AppError::Config("无效的文件名".to_string()))? .to_string_lossy() .to_string(); let ts = std::time::SystemTime::now() @@ -202,12 +204,10 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> { tmp.push(format!("{}.tmp.{}", file_name, ts)); { - let mut f = fs::File::create(&tmp) - .map_err(|e| format!("创建临时文件失败: {}: {}", tmp.display(), e))?; - f.write_all(data) - .map_err(|e| format!("写入临时文件失败: {}: {}", tmp.display(), e))?; - f.flush() - .map_err(|e| format!("刷新临时文件失败: {}: {}", tmp.display(), 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))?; } #[cfg(unix)] @@ -225,25 +225,25 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> { if path.exists() { let _ = fs::remove_file(path); } - fs::rename(&tmp, path).map_err(|e| { - format!( - "原子替换失败: {} -> {}: {}", + fs::rename(&tmp, path).map_err(|e| AppError::IoContext { + context: format!( + "原子替换失败: {} -> {}", tmp.display(), - path.display(), - e - ) + path.display() + ), + source: e, })?; } #[cfg(not(windows))] { - fs::rename(&tmp, path).map_err(|e| { - format!( - "原子替换失败: {} -> {}: {}", + fs::rename(&tmp, path).map_err(|e| AppError::IoContext { + context: format!( + "原子替换失败: {} -> {}", tmp.display(), - path.display(), - e - ) + path.display() + ), + source: e, })?; } Ok(()) @@ -285,15 +285,22 @@ mod tests { } /// 复制文件 -pub fn copy_file(from: &Path, to: &Path) -> Result<(), String> { - fs::copy(from, to).map_err(|e| format!("复制文件失败: {}", e))?; +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() + ), + source: e, + })?; Ok(()) } /// 删除文件 -pub fn delete_file(path: &Path) -> Result<(), String> { +pub fn delete_file(path: &Path) -> Result<(), AppError> { if path.exists() { - fs::remove_file(path).map_err(|e| format!("删除文件失败: {}", e))?; + fs::remove_file(path).map_err(|e| AppError::io(path, e))?; } Ok(()) } diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..8fc4f7c --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,84 @@ +use std::path::Path; +use std::sync::PoisonError; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("配置错误: {0}")] + Config(String), + #[error("无效输入: {0}")] + InvalidInput(String), + #[error("IO 错误: {path}: {source}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, + #[error("{context}: {source}")] + IoContext { + context: String, + #[source] + source: std::io::Error, + }, + #[error("JSON 解析错误: {path}: {source}")] + Json { + path: String, + #[source] + source: serde_json::Error, + }, + #[error("JSON 序列化失败: {source}")] + JsonSerialize { + #[source] + source: serde_json::Error, + }, + #[error("TOML 解析错误: {path}: {source}")] + Toml { + path: String, + #[source] + source: toml::de::Error, + }, + #[error("锁获取失败: {0}")] + Lock(String), + #[error("供应商不存在: {0}")] + ProviderNotFound(String), + #[error("MCP 校验失败: {0}")] + McpValidation(String), + #[error("{0}")] + Message(String), +} + +impl AppError { + pub fn io(path: impl AsRef, source: std::io::Error) -> Self { + Self::Io { + path: path.as_ref().display().to_string(), + source, + } + } + + pub fn json(path: impl AsRef, source: serde_json::Error) -> Self { + Self::Json { + path: path.as_ref().display().to_string(), + source, + } + } + + pub fn toml(path: impl AsRef, source: toml::de::Error) -> Self { + Self::Toml { + path: path.as_ref().display().to_string(), + source, + } + } +} + +impl From> for AppError { + fn from(err: PoisonError) -> Self { + Self::Lock(err.to_string()) + } +} + +impl From for String { + fn from(err: AppError) -> Self { + err.to_string() + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6db0d96..eb6b93a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod claude_plugin; mod codex_config; mod commands; mod config; +mod error; mod import_export; mod mcp; mod migration; diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 05bb056..3d019c2 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -315,7 +315,7 @@ pub fn set_enabled_and_sync_for( /// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), String> { let enabled = collect_enabled_servers(&config.mcp.claude); - crate::claude_mcp::set_mcp_servers_map(&enabled) + crate::claude_mcp::set_mcp_servers_map(&enabled).map_err(Into::into) } /// 从 ~/.claude.json 导入 mcpServers 到 config.json(设为 enabled=true)。 diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 194c33b..d1d4d66 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -1,4 +1,5 @@ use crate::app_config::MultiAppConfig; +use crate::error::AppError; use std::sync::Mutex; /// 全局应用状态 @@ -20,11 +21,8 @@ impl AppState { } /// 保存配置到文件 - pub fn save(&self) -> Result<(), String> { - let config = self - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; + pub fn save(&self) -> Result<(), AppError> { + let config = self.config.lock().map_err(AppError::from)?; config.save() }