use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::str::FromStr; /// MCP 配置:单客户端维度(claude 或 codex 下的一组服务器) #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct McpConfig { /// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段) #[serde(default)] pub servers: HashMap, } /// MCP 根:按客户端分开维护(无历史兼容压力,直接以 v2 结构落地) #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct McpRoot { #[serde(default)] pub claude: McpConfig, #[serde(default)] pub codex: McpConfig, #[serde(default)] pub gemini: McpConfig, // Gemini MCP 配置(预留) } /// Prompt 配置:单客户端维度 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PromptConfig { #[serde(default)] pub prompts: HashMap, } /// Prompt 根:按客户端分开维护 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PromptRoot { #[serde(default)] pub claude: PromptConfig, #[serde(default)] pub codex: PromptConfig, #[serde(default)] pub gemini: PromptConfig, } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; use crate::error::AppError; use crate::prompt_files::prompt_file_path; use crate::provider::ProviderManager; /// 应用类型 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AppType { Claude, Codex, Gemini, // 新增 } impl AppType { pub fn as_str(&self) -> &str { match self { AppType::Claude => "claude", AppType::Codex => "codex", AppType::Gemini => "gemini", // 新增 } } } impl FromStr for AppType { type Err = AppError; fn from_str(s: &str) -> Result { let normalized = s.trim().to_lowercase(); match normalized.as_str() { "claude" => Ok(AppType::Claude), "codex" => Ok(AppType::Codex), "gemini" => Ok(AppType::Gemini), // 新增 other => Err(AppError::localized( "unsupported_app", format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"), format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."), )), } } } /// 多应用配置结构(向后兼容) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MultiAppConfig { #[serde(default = "default_version")] pub version: u32, /// 应用管理器(claude/codex) #[serde(flatten)] pub apps: HashMap, /// MCP 配置(按客户端分治) #[serde(default)] pub mcp: McpRoot, /// Prompt 配置(按客户端分治) #[serde(default)] pub prompts: PromptRoot, /// Claude 通用配置片段(JSON 字符串,用于跨供应商共享配置) #[serde(default, skip_serializing_if = "Option::is_none")] pub claude_common_config_snippet: Option, } fn default_version() -> u32 { 2 } impl Default for MultiAppConfig { fn default() -> Self { let mut apps = HashMap::new(); apps.insert("claude".to_string(), ProviderManager::default()); apps.insert("codex".to_string(), ProviderManager::default()); apps.insert("gemini".to_string(), ProviderManager::default()); // 新增 Self { version: 2, apps, mcp: McpRoot::default(), prompts: PromptRoot::default(), claude_common_config_snippet: None, } } } impl MultiAppConfig { /// 从文件加载配置(仅支持 v2 结构) pub fn load() -> Result { let config_path = get_app_config_path(); if !config_path.exists() { log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词"); // 使用新的方法,支持自动导入提示词 let config = Self::default_with_auto_import()?; // 立即保存到磁盘 config.save()?; return Ok(config); } // 尝试读取文件 let content = std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; // 先解析为 Value,以便严格判定是否为 v1 结构; // 满足:顶层同时包含 providers(object) + current(string),且不包含 version/apps/mcp 关键键,即视为 v1 let value: serde_json::Value = serde_json::from_str(&content).map_err(|e| AppError::json(&config_path, e))?; let is_v1 = value.as_object().is_some_and(|map| { let has_providers = map.get("providers").map(|v| v.is_object()).unwrap_or(false); let has_current = map.get("current").map(|v| v.is_string()).unwrap_or(false); // v1 的充分必要条件:有 providers 和 current,且 apps 不存在(version/mcp 可能存在但不作为 v2 判据) let has_apps = map.contains_key("apps"); has_providers && has_current && !has_apps }); if is_v1 { return Err(AppError::localized( "config.unsupported_v1", "检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch/config.json,将顶层结构调整为:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n", "Detected legacy v1 config. Runtime auto-migration is no longer supported.\n\nSolutions:\n1. Install v3.2.x for one-time auto-migration\n2. Or manually edit ~/.cc-switch/config.json to adjust the top-level structure:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n", )); } // 解析 v2 结构 let mut config: Self = serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?; // 确保 gemini 应用存在(兼容旧配置文件) if !config.apps.contains_key("gemini") { config .apps .insert("gemini".to_string(), ProviderManager::default()); } Ok(config) } /// 保存配置到文件 pub fn save(&self) -> Result<(), AppError> { let config_path = get_app_config_path(); // 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak,再写入新内容 if config_path.exists() { let backup_path = get_app_config_dir().join("config.json.bak"); if let Err(e) = copy_file(&config_path, &backup_path) { log::warn!("备份 config.json 到 .bak 失败: {e}"); } } write_json_file(&config_path, self)?; Ok(()) } /// 获取指定应用的管理器 pub fn get_manager(&self, app: &AppType) -> Option<&ProviderManager> { self.apps.get(app.as_str()) } /// 获取指定应用的管理器(可变引用) pub fn get_manager_mut(&mut self, app: &AppType) -> Option<&mut ProviderManager> { self.apps.get_mut(app.as_str()) } /// 确保应用存在 pub fn ensure_app(&mut self, app: &AppType) { if !self.apps.contains_key(app.as_str()) { self.apps .insert(app.as_str().to_string(), ProviderManager::default()); } } /// 获取指定客户端的 MCP 配置(不可变引用) pub fn mcp_for(&self, app: &AppType) -> &McpConfig { match app { AppType::Claude => &self.mcp.claude, AppType::Codex => &self.mcp.codex, AppType::Gemini => &self.mcp.gemini, } } /// 获取指定客户端的 MCP 配置(可变引用) pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig { match app { AppType::Claude => &mut self.mcp.claude, AppType::Codex => &mut self.mcp.codex, AppType::Gemini => &mut self.mcp.gemini, } } /// 创建默认配置并自动导入已存在的提示词文件 fn default_with_auto_import() -> Result { log::info!("首次启动,创建默认配置并检测提示词文件"); let mut config = Self::default(); // 为每个应用尝试自动导入提示词 Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?; Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?; Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?; Ok(config) } /// 检查并自动导入单个应用的提示词文件 fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<(), AppError> { let file_path = prompt_file_path(&app)?; // 检查文件是否存在 if !file_path.exists() { log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}"); return Ok(()); } // 读取文件内容 let content = match std::fs::read_to_string(&file_path) { Ok(c) => c, Err(e) => { log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}"); return Ok(()); // 失败时不中断,继续处理其他应用 } }; // 检查内容是否为空 if content.trim().is_empty() { log::debug!("提示词文件内容为空,跳过导入: {file_path:?}"); return Ok(()); } log::info!("发现提示词文件,自动导入: {file_path:?}"); // 创建提示词对象 let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() as i64; let id = format!("auto-imported-{timestamp}"); let prompt = crate::prompt::Prompt { id: id.clone(), name: format!( "Auto-imported Prompt {}", chrono::Local::now().format("%Y-%m-%d %H:%M") ), content, description: Some("Automatically imported on first launch".to_string()), enabled: true, // 自动启用 created_at: Some(timestamp), updated_at: Some(timestamp), }; // 插入到对应的应用配置中 let prompts = match app { AppType::Claude => &mut config.prompts.claude.prompts, AppType::Codex => &mut config.prompts.codex.prompts, AppType::Gemini => &mut config.prompts.gemini.prompts, }; prompts.insert(id, prompt); log::info!("自动导入完成: {}", app.as_str()); Ok(()) } } #[cfg(test)] mod tests { use super::*; use serial_test::serial; use std::env; use std::fs; use tempfile::TempDir; struct TempHome { #[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期 dir: TempDir, original_home: Option, original_userprofile: Option, } impl TempHome { fn new() -> Self { let dir = TempDir::new().expect("failed to create temp home"); let original_home = env::var("HOME").ok(); let original_userprofile = env::var("USERPROFILE").ok(); env::set_var("HOME", dir.path()); env::set_var("USERPROFILE", dir.path()); Self { dir, original_home, original_userprofile, } } } impl Drop for TempHome { fn drop(&mut self) { match &self.original_home { Some(value) => env::set_var("HOME", value), None => env::remove_var("HOME"), } match &self.original_userprofile { Some(value) => env::set_var("USERPROFILE", value), None => env::remove_var("USERPROFILE"), } } } fn write_prompt_file(app: AppType, content: &str) { let path = crate::prompt_files::prompt_file_path(&app).expect("prompt path"); if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create parent dir"); } fs::write(path, content).expect("write prompt"); } #[test] #[serial] fn auto_imports_existing_prompt_when_config_missing() { let _home = TempHome::new(); write_prompt_file(AppType::Claude, "# hello"); let config = MultiAppConfig::load().expect("load config"); assert_eq!(config.prompts.claude.prompts.len(), 1); let prompt = config .prompts .claude .prompts .values() .next() .expect("prompt exists"); assert!(prompt.enabled); assert_eq!(prompt.content, "# hello"); let config_path = crate::config::get_app_config_path(); assert!( config_path.exists(), "auto import should persist config to disk" ); } #[test] #[serial] fn skips_empty_prompt_files_during_import() { let _home = TempHome::new(); write_prompt_file(AppType::Claude, " \n "); let config = MultiAppConfig::load().expect("load config"); assert!( config.prompts.claude.prompts.is_empty(), "empty files must be ignored" ); } #[test] #[serial] fn auto_import_happens_only_once() { let _home = TempHome::new(); write_prompt_file(AppType::Claude, "first version"); let first = MultiAppConfig::load().expect("load config"); assert_eq!(first.prompts.claude.prompts.len(), 1); let claude_prompt = first .prompts .claude .prompts .values() .next() .expect("prompt exists") .content .clone(); assert_eq!(claude_prompt, "first version"); // 覆盖文件内容,但保留 config.json write_prompt_file(AppType::Claude, "second version"); let second = MultiAppConfig::load().expect("load config again"); assert_eq!(second.prompts.claude.prompts.len(), 1); let prompt = second .prompts .claude .prompts .values() .next() .expect("prompt exists"); assert_eq!( prompt.content, "first version", "should not re-import when config already exists" ); } #[test] #[serial] fn auto_imports_gemini_prompt_on_first_launch() { let _home = TempHome::new(); write_prompt_file(AppType::Gemini, "# Gemini Prompt\n\nTest content"); let config = MultiAppConfig::load().expect("load config"); assert_eq!(config.prompts.gemini.prompts.len(), 1); let prompt = config .prompts .gemini .prompts .values() .next() .expect("gemini prompt exists"); assert!(prompt.enabled, "gemini prompt should be enabled"); assert_eq!(prompt.content, "# Gemini Prompt\n\nTest content"); assert_eq!( prompt.description, Some("Automatically imported on first launch".to_string()) ); } #[test] #[serial] fn auto_imports_all_three_apps_prompts() { let _home = TempHome::new(); write_prompt_file(AppType::Claude, "# Claude prompt"); write_prompt_file(AppType::Codex, "# Codex prompt"); write_prompt_file(AppType::Gemini, "# Gemini prompt"); let config = MultiAppConfig::load().expect("load config"); // 验证所有三个应用的提示词都被导入 assert_eq!(config.prompts.claude.prompts.len(), 1); assert_eq!(config.prompts.codex.prompts.len(), 1); assert_eq!(config.prompts.gemini.prompts.len(), 1); // 验证所有提示词都被启用 assert!( config .prompts .claude .prompts .values() .next() .unwrap() .enabled ); assert!( config .prompts .codex .prompts .values() .next() .unwrap() .enabled ); assert!( config .prompts .gemini .prompts .values() .next() .unwrap() .enabled ); } }