Skip to main content

cc_switch_lib/
app_config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::str::FromStr;
4
5use crate::services::skill::SkillStore;
6
7/// MCP 服务器应用状态(标记应用到哪些客户端)
8#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
9pub struct McpApps {
10    #[serde(default)]
11    pub claude: bool,
12    #[serde(default)]
13    pub codex: bool,
14    #[serde(default)]
15    pub gemini: bool,
16    #[serde(default)]
17    pub opencode: bool,
18    #[serde(default)]
19    pub openclaw: bool,
20    #[serde(default)]
21    pub hermes: bool,
22}
23
24impl McpApps {
25    /// 检查指定应用是否启用
26    pub fn is_enabled_for(&self, app: &AppType) -> bool {
27        match app {
28            AppType::Claude => self.claude,
29            AppType::Codex => self.codex,
30            AppType::Gemini => self.gemini,
31            AppType::OpenCode => self.opencode,
32            AppType::OpenClaw => self.openclaw,
33            AppType::Hermes => self.hermes,
34        }
35    }
36
37    /// 设置指定应用的启用状态
38    pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
39        match app {
40            AppType::Claude => self.claude = enabled,
41            AppType::Codex => self.codex = enabled,
42            AppType::Gemini => self.gemini = enabled,
43            AppType::OpenCode => self.opencode = enabled,
44            AppType::OpenClaw => self.openclaw = enabled,
45            AppType::Hermes => self.hermes = enabled,
46        }
47    }
48
49    /// 获取所有启用的应用列表
50    pub fn enabled_apps(&self) -> Vec<AppType> {
51        let mut apps = Vec::new();
52        if self.claude {
53            apps.push(AppType::Claude);
54        }
55        if self.codex {
56            apps.push(AppType::Codex);
57        }
58        if self.gemini {
59            apps.push(AppType::Gemini);
60        }
61        if self.opencode {
62            apps.push(AppType::OpenCode);
63        }
64        if self.openclaw {
65            apps.push(AppType::OpenClaw);
66        }
67        if self.hermes {
68            apps.push(AppType::Hermes);
69        }
70        apps
71    }
72
73    /// 检查是否所有应用都未启用
74    pub fn is_empty(&self) -> bool {
75        !self.claude
76            && !self.codex
77            && !self.gemini
78            && !self.opencode
79            && !self.openclaw
80            && !self.hermes
81    }
82}
83
84/// Skill 应用启用状态(标记 Skill 应用到哪些客户端)
85#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
86pub struct SkillApps {
87    #[serde(default)]
88    pub claude: bool,
89    #[serde(default)]
90    pub codex: bool,
91    #[serde(default)]
92    pub gemini: bool,
93    #[serde(default)]
94    pub opencode: bool,
95    #[serde(default)]
96    pub openclaw: bool,
97    #[serde(default)]
98    pub hermes: bool,
99}
100
101impl SkillApps {
102    pub fn is_enabled_for(&self, app: &AppType) -> bool {
103        match app {
104            AppType::Claude => self.claude,
105            AppType::Codex => self.codex,
106            AppType::Gemini => self.gemini,
107            AppType::OpenCode => self.opencode,
108            AppType::OpenClaw => self.openclaw,
109            AppType::Hermes => self.hermes,
110        }
111    }
112
113    pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
114        match app {
115            AppType::Claude => self.claude = enabled,
116            AppType::Codex => self.codex = enabled,
117            AppType::Gemini => self.gemini = enabled,
118            AppType::OpenCode => self.opencode = enabled,
119            AppType::OpenClaw => self.openclaw = enabled,
120            AppType::Hermes => self.hermes = enabled,
121        }
122    }
123
124    pub fn is_empty(&self) -> bool {
125        !self.claude
126            && !self.codex
127            && !self.gemini
128            && !self.opencode
129            && !self.openclaw
130            && !self.hermes
131    }
132
133    pub fn only(app: &AppType) -> Self {
134        let mut apps = Self::default();
135        apps.set_enabled_for(app, true);
136        apps
137    }
138
139    pub fn from_labels(labels: &[String]) -> Self {
140        let mut apps = Self::default();
141        for label in labels {
142            if let Ok(app) = label.parse::<AppType>() {
143                apps.set_enabled_for(&app, true);
144            }
145        }
146        apps
147    }
148
149    pub fn merge_enabled(&mut self, other: &SkillApps) {
150        self.claude |= other.claude;
151        self.codex |= other.codex;
152        self.gemini |= other.gemini;
153        self.opencode |= other.opencode;
154        self.openclaw |= other.openclaw;
155        self.hermes |= other.hermes;
156    }
157}
158
159/// 已安装的 Skill(上游兼容结构)
160#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct InstalledSkill {
163    /// 唯一标识符(格式:"owner/repo:directory" 或 "local:directory")
164    pub id: String,
165    /// 显示名称
166    pub name: String,
167    /// 描述
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub description: Option<String>,
170    /// 安装目录名(在 SSOT 目录中的子目录名)
171    pub directory: String,
172    /// 仓库所有者(GitHub 用户/组织)
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub repo_owner: Option<String>,
175    /// 仓库名称
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub repo_name: Option<String>,
178    /// 仓库分支
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub repo_branch: Option<String>,
181    /// README URL
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub readme_url: Option<String>,
184    /// 应用启用状态
185    pub apps: SkillApps,
186    /// 安装时间(Unix 时间戳)
187    pub installed_at: i64,
188}
189
190/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理)
191#[derive(Debug, Clone, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase")]
193pub struct UnmanagedSkill {
194    /// 目录名
195    pub directory: String,
196    /// 显示名称(从 SKILL.md 解析)
197    pub name: String,
198    /// 描述
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub description: Option<String>,
201    /// 在哪些应用目录中发现(如 ["claude", "codex"])
202    pub found_in: Vec<String>,
203}
204
205/// MCP 服务器定义(v3.7.0 统一结构)
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct McpServer {
208    pub id: String,
209    pub name: String,
210    pub server: serde_json::Value,
211    pub apps: McpApps,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub description: Option<String>,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub homepage: Option<String>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub docs: Option<String>,
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub tags: Vec<String>,
220}
221
222/// MCP 配置:单客户端维度(v3.6.x 及以前,保留用于向后兼容)
223#[derive(Debug, Clone, Serialize, Deserialize, Default)]
224pub struct McpConfig {
225    /// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
226    #[serde(default)]
227    pub servers: HashMap<String, serde_json::Value>,
228}
229
230impl McpConfig {
231    /// 检查配置是否为空
232    pub fn is_empty(&self) -> bool {
233        self.servers.is_empty()
234    }
235}
236
237/// MCP 根配置(v3.7.0 新旧结构并存)
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct McpRoot {
240    /// 统一的 MCP 服务器存储(v3.7.0+)
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub servers: Option<HashMap<String, McpServer>>,
243
244    /// 旧的分应用存储(v3.6.x 及以前,保留用于迁移)
245    #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
246    pub claude: McpConfig,
247    #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
248    pub codex: McpConfig,
249    #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
250    pub gemini: McpConfig,
251    #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
252    pub opencode: McpConfig,
253    #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
254    pub openclaw: McpConfig,
255    #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
256    pub hermes: McpConfig,
257}
258
259impl Default for McpRoot {
260    fn default() -> Self {
261        Self {
262            // v3.7.0+ 默认使用新的统一结构(空 HashMap)
263            servers: Some(HashMap::new()),
264            // 旧结构保持空,仅用于反序列化旧配置时的迁移
265            claude: McpConfig::default(),
266            codex: McpConfig::default(),
267            gemini: McpConfig::default(),
268            opencode: McpConfig::default(),
269            openclaw: McpConfig::default(),
270            hermes: McpConfig::default(),
271        }
272    }
273}
274
275/// Prompt 配置:单客户端维度
276#[derive(Debug, Clone, Serialize, Deserialize, Default)]
277pub struct PromptConfig {
278    #[serde(default)]
279    pub prompts: HashMap<String, crate::prompt::Prompt>,
280}
281
282/// Prompt 根:按客户端分开维护
283#[derive(Debug, Clone, Serialize, Deserialize, Default)]
284pub struct PromptRoot {
285    #[serde(default)]
286    pub claude: PromptConfig,
287    #[serde(default)]
288    pub codex: PromptConfig,
289    #[serde(default)]
290    pub gemini: PromptConfig,
291    #[serde(default)]
292    pub opencode: PromptConfig,
293    #[serde(default)]
294    pub openclaw: PromptConfig,
295    #[serde(default)]
296    pub hermes: PromptConfig,
297}
298
299use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
300use crate::error::AppError;
301use crate::prompt_files::prompt_file_path;
302use crate::provider::ProviderManager;
303
304/// 应用类型
305#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, clap::ValueEnum)]
306#[serde(rename_all = "lowercase")]
307pub enum AppType {
308    Claude,
309    Codex,
310    Gemini,
311    OpenCode,
312    OpenClaw,
313    Hermes,
314}
315
316/// Apps shown in the MCP server picker.
317pub const MCP_PICKER_APPS: &[AppType] = &[
318    AppType::Claude,
319    AppType::Codex,
320    AppType::Gemini,
321    AppType::OpenCode,
322    AppType::OpenClaw,
323    AppType::Hermes,
324];
325
326/// Apps shown in the "Visible Apps" settings picker (all apps).
327pub const VISIBLE_PICKER_APPS: &[AppType] = &[
328    AppType::Claude,
329    AppType::Codex,
330    AppType::Gemini,
331    AppType::OpenCode,
332    AppType::OpenClaw,
333    AppType::Hermes,
334];
335
336/// Apps shown in the skills picker.
337pub const SKILLS_PICKER_APPS: &[AppType] = &[
338    AppType::Claude,
339    AppType::Codex,
340    AppType::Gemini,
341    AppType::OpenCode,
342    AppType::OpenClaw,
343    AppType::Hermes,
344];
345
346impl AppType {
347    pub fn as_str(&self) -> &'static str {
348        match self {
349            AppType::Claude => "claude",
350            AppType::Codex => "codex",
351            AppType::Gemini => "gemini",
352            AppType::OpenCode => "opencode",
353            AppType::OpenClaw => "openclaw",
354            AppType::Hermes => "hermes",
355        }
356    }
357
358    pub fn is_additive_mode(&self) -> bool {
359        matches!(
360            self,
361            AppType::OpenCode | AppType::OpenClaw | AppType::Hermes
362        )
363    }
364
365    pub fn all() -> impl Iterator<Item = AppType> {
366        [
367            AppType::Claude,
368            AppType::Codex,
369            AppType::Gemini,
370            AppType::OpenCode,
371            AppType::OpenClaw,
372            AppType::Hermes,
373        ]
374        .into_iter()
375    }
376}
377
378impl std::fmt::Display for AppType {
379    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380        write!(f, "{}", self.as_str())
381    }
382}
383
384impl FromStr for AppType {
385    type Err = AppError;
386
387    fn from_str(s: &str) -> Result<Self, Self::Err> {
388        let normalized = s.trim().to_lowercase();
389        match normalized.as_str() {
390            "claude" => Ok(AppType::Claude),
391            "codex" => Ok(AppType::Codex),
392            "gemini" => Ok(AppType::Gemini),
393            "opencode" => Ok(AppType::OpenCode),
394            "openclaw" => Ok(AppType::OpenClaw),
395            "hermes" => Ok(AppType::Hermes),
396            other => Err(AppError::localized(
397                "unsupported_app",
398                format!(
399                    "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw, hermes。"
400                ),
401                format!(
402                    "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes."
403                ),
404            )),
405        }
406    }
407}
408
409/// 通用配置片段(按应用分治)
410#[derive(Debug, Clone, Serialize, Deserialize, Default)]
411pub struct CommonConfigSnippets {
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub claude: Option<String>,
414
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub codex: Option<String>,
417
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub gemini: Option<String>,
420
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub opencode: Option<String>,
423
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub openclaw: Option<String>,
426
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub hermes: Option<String>,
429}
430
431impl CommonConfigSnippets {
432    /// 获取指定应用的通用配置片段
433    pub fn get(&self, app: &AppType) -> Option<&String> {
434        match app {
435            AppType::Claude => self.claude.as_ref(),
436            AppType::Codex => self.codex.as_ref(),
437            AppType::Gemini => self.gemini.as_ref(),
438            AppType::OpenCode => self.opencode.as_ref(),
439            AppType::OpenClaw => self.openclaw.as_ref(),
440            AppType::Hermes => self.hermes.as_ref(),
441        }
442    }
443
444    /// 设置指定应用的通用配置片段
445    pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
446        match app {
447            AppType::Claude => self.claude = snippet,
448            AppType::Codex => self.codex = snippet,
449            AppType::Gemini => self.gemini = snippet,
450            AppType::OpenCode => self.opencode = snippet,
451            AppType::OpenClaw => self.openclaw = snippet,
452            AppType::Hermes => self.hermes = snippet,
453        }
454    }
455}
456
457/// 多应用配置结构(向后兼容)
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct MultiAppConfig {
460    #[serde(default = "default_version")]
461    pub version: u32,
462    /// 应用管理器(claude/codex)
463    #[serde(flatten)]
464    pub apps: HashMap<String, ProviderManager>,
465    /// MCP 配置(按客户端分治)
466    #[serde(default)]
467    pub mcp: McpRoot,
468    /// Prompt 配置(按客户端分治)
469    #[serde(default)]
470    pub prompts: PromptRoot,
471    /// Claude Skills 配置
472    #[serde(default)]
473    pub skills: SkillStore,
474    /// 通用配置片段(按应用分治)
475    #[serde(default)]
476    pub common_config_snippets: CommonConfigSnippets,
477    /// Claude 通用配置片段(旧字段,用于向后兼容迁移)
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub claude_common_config_snippet: Option<String>,
480}
481
482fn default_version() -> u32 {
483    2
484}
485
486impl Default for MultiAppConfig {
487    fn default() -> Self {
488        let mut apps = HashMap::new();
489        apps.insert("claude".to_string(), ProviderManager::default());
490        apps.insert("codex".to_string(), ProviderManager::default());
491        apps.insert("gemini".to_string(), ProviderManager::default());
492        apps.insert("opencode".to_string(), ProviderManager::default());
493        apps.insert("openclaw".to_string(), ProviderManager::default());
494        apps.insert("hermes".to_string(), ProviderManager::default());
495
496        Self {
497            version: 2,
498            apps,
499            mcp: McpRoot::default(),
500            prompts: PromptRoot::default(),
501            skills: SkillStore::default(),
502            common_config_snippets: CommonConfigSnippets::default(),
503            claude_common_config_snippet: None,
504        }
505    }
506}
507
508impl MultiAppConfig {
509    /// 从文件加载配置(仅支持 v2 结构)
510    pub fn load() -> Result<Self, AppError> {
511        let config_path = get_app_config_path();
512
513        if !config_path.exists() {
514            log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词");
515            // 使用新的方法,支持自动导入提示词
516            let config = Self::default_with_auto_import()?;
517            // 立即保存到磁盘
518            config.save()?;
519            return Ok(config);
520        }
521
522        // 尝试读取文件
523        let content =
524            std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
525
526        // 先解析为 Value,以便严格判定是否为 v1 结构;
527        // 满足:顶层同时包含 providers(object) + current(string),且不包含 version/apps/mcp 关键键,即视为 v1
528        let value: serde_json::Value =
529            serde_json::from_str(&content).map_err(|e| AppError::json(&config_path, e))?;
530        let is_v1 = value.as_object().is_some_and(|map| {
531            let has_providers = map.get("providers").map(|v| v.is_object()).unwrap_or(false);
532            let has_current = map.get("current").map(|v| v.is_string()).unwrap_or(false);
533            // v1 的充分必要条件:有 providers 和 current,且 apps 不存在(version/mcp 可能存在但不作为 v2 判据)
534            let has_apps = map.contains_key("apps");
535            has_providers && has_current && !has_apps
536        });
537        if is_v1 {
538            return Err(AppError::localized(
539                "config.unsupported_v1",
540                "检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch-tui/config.json,将顶层结构调整为:\n   {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
541                "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-tui/config.json to adjust the top-level structure:\n   {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
542            ));
543        }
544
545        let has_skills_in_config = value
546            .as_object()
547            .is_some_and(|map| map.contains_key("skills"));
548
549        // 解析 v2 结构
550        let mut config: Self =
551            serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
552        let mut updated = false;
553
554        if !has_skills_in_config {
555            let skills_path = get_app_config_dir().join("skills.json");
556            if skills_path.exists() {
557                match std::fs::read_to_string(&skills_path) {
558                    Ok(content) => match serde_json::from_str::<SkillStore>(&content) {
559                        Ok(store) => {
560                            config.skills = store;
561                            updated = true;
562                            log::info!("已从旧版 skills.json 导入 Claude Skills 配置");
563                        }
564                        Err(e) => {
565                            log::warn!("解析旧版 skills.json 失败: {e}");
566                        }
567                    },
568                    Err(e) => {
569                        log::warn!("读取旧版 skills.json 失败: {e}");
570                    }
571                }
572            }
573        }
574
575        // 确保 gemini 应用存在(兼容旧配置文件)
576        if !config.apps.contains_key("gemini") {
577            config
578                .apps
579                .insert("gemini".to_string(), ProviderManager::default());
580            updated = true;
581        }
582
583        if !config.apps.contains_key("opencode") {
584            config
585                .apps
586                .insert("opencode".to_string(), ProviderManager::default());
587            updated = true;
588        }
589
590        if !config.apps.contains_key("openclaw") {
591            config
592                .apps
593                .insert("openclaw".to_string(), ProviderManager::default());
594            updated = true;
595        }
596
597        // 执行 MCP 迁移(v3.6.x → v3.7.0)
598        let migrated = config.migrate_mcp_to_unified()?;
599        if migrated {
600            log::info!("MCP 配置已迁移到 v3.7.0 统一结构,保存配置...");
601            updated = true;
602        }
603
604        // 对于已经存在的配置文件,如果此前版本还没有 Prompt 功能,
605        // 且 prompts 仍然是空的,则尝试自动导入现有提示词文件。
606        let imported_prompts = config.maybe_auto_import_prompts_for_existing_config()?;
607        if imported_prompts {
608            updated = true;
609        }
610
611        // 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
612        if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
613            log::info!(
614                "迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
615            );
616            config.common_config_snippets.claude = Some(old_claude_snippet);
617            updated = true;
618        }
619
620        if updated {
621            log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
622            config.save()?;
623        }
624
625        Ok(config)
626    }
627
628    /// 保存配置到文件
629    pub fn save(&self) -> Result<(), AppError> {
630        let config_path = get_app_config_path();
631        // 先备份旧版(若存在)到 ~/.cc-switch-tui/config.json.bak,再写入新内容
632        if config_path.exists() {
633            let backup_path = get_app_config_dir().join("config.json.bak");
634            if let Err(e) = copy_file(&config_path, &backup_path) {
635                log::warn!("备份 config.json 到 .bak 失败: {e}");
636            }
637        }
638
639        write_json_file(&config_path, self)?;
640        Ok(())
641    }
642
643    /// 获取指定应用的管理器
644    pub fn get_manager(&self, app: &AppType) -> Option<&ProviderManager> {
645        self.apps.get(app.as_str())
646    }
647
648    /// 获取指定应用的管理器(可变引用)
649    pub fn get_manager_mut(&mut self, app: &AppType) -> Option<&mut ProviderManager> {
650        self.apps.get_mut(app.as_str())
651    }
652
653    /// 确保应用存在
654    pub fn ensure_app(&mut self, app: &AppType) {
655        if !self.apps.contains_key(app.as_str()) {
656            self.apps
657                .insert(app.as_str().to_string(), ProviderManager::default());
658        }
659    }
660
661    /// 获取指定客户端的 MCP 配置(不可变引用)
662    pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
663        match app {
664            AppType::Claude => &self.mcp.claude,
665            AppType::Codex => &self.mcp.codex,
666            AppType::Gemini => &self.mcp.gemini,
667            AppType::OpenCode => &self.mcp.opencode,
668            AppType::OpenClaw => &self.mcp.openclaw,
669            AppType::Hermes => &self.mcp.hermes,
670        }
671    }
672
673    /// 获取指定客户端的 MCP 配置(可变引用)
674    pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
675        match app {
676            AppType::Claude => &mut self.mcp.claude,
677            AppType::Codex => &mut self.mcp.codex,
678            AppType::Gemini => &mut self.mcp.gemini,
679            AppType::OpenCode => &mut self.mcp.opencode,
680            AppType::OpenClaw => &mut self.mcp.openclaw,
681            AppType::Hermes => &mut self.mcp.hermes,
682        }
683    }
684
685    /// 创建默认配置并自动导入已存在的提示词文件
686    fn default_with_auto_import() -> Result<Self, AppError> {
687        log::info!("首次启动,创建默认配置并检测提示词文件");
688
689        let mut config = Self::default();
690
691        // 为每个应用尝试自动导入提示词
692        Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
693        Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
694        Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
695        Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
696        Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
697
698        Ok(config)
699    }
700
701    /// 已存在配置文件时的 Prompt 自动导入逻辑
702    ///
703    /// 适用于「老版本已经生成过 config.json,但当时还没有 Prompt 功能」的升级场景。
704    /// 判定规则:
705    /// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户)
706    /// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md)
707    ///
708    /// 返回值:
709    /// - Ok(true)  表示至少有一个应用成功导入了提示词
710    /// - Ok(false) 表示无需导入或未导入任何内容
711    fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
712        // 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
713        if !self.prompts.claude.prompts.is_empty()
714            || !self.prompts.codex.prompts.is_empty()
715            || !self.prompts.gemini.prompts.is_empty()
716            || !self.prompts.opencode.prompts.is_empty()
717            || !self.prompts.openclaw.prompts.is_empty()
718        {
719            return Ok(false);
720        }
721
722        log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
723
724        let mut imported = false;
725        for app in [
726            AppType::Claude,
727            AppType::Codex,
728            AppType::Gemini,
729            AppType::OpenCode,
730            AppType::OpenClaw,
731            AppType::Hermes,
732        ] {
733            // 复用已有的单应用导入逻辑
734            if Self::auto_import_prompt_if_exists(self, app)? {
735                imported = true;
736            }
737        }
738
739        Ok(imported)
740    }
741
742    /// 检查并自动导入单个应用的提示词文件
743    ///
744    /// 返回值:
745    /// - Ok(true)  表示成功导入了非空文件
746    /// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败)
747    fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<bool, AppError> {
748        let file_path = prompt_file_path(&app)?;
749
750        // 检查文件是否存在
751        if !file_path.exists() {
752            log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}");
753            return Ok(false);
754        }
755
756        // 读取文件内容
757        let content = match std::fs::read_to_string(&file_path) {
758            Ok(c) => c,
759            Err(e) => {
760                log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}");
761                return Ok(false); // 失败时不中断,继续处理其他应用
762            }
763        };
764
765        // 检查内容是否为空
766        if content.trim().is_empty() {
767            log::debug!("提示词文件内容为空,跳过导入: {file_path:?}");
768            return Ok(false);
769        }
770
771        log::info!("发现提示词文件,自动导入: {file_path:?}");
772
773        // 创建提示词对象
774        let timestamp = std::time::SystemTime::now()
775            .duration_since(std::time::UNIX_EPOCH)
776            .unwrap()
777            .as_secs() as i64;
778
779        let id = format!("auto-imported-{timestamp}");
780        let prompt = crate::prompt::Prompt {
781            id: id.clone(),
782            name: format!(
783                "Auto-imported Prompt {}",
784                chrono::Local::now().format("%Y-%m-%d %H:%M")
785            ),
786            content,
787            description: Some("Automatically imported on first launch".to_string()),
788            enabled: true, // 自动启用
789            created_at: Some(timestamp),
790            updated_at: Some(timestamp),
791        };
792
793        // 插入到对应的应用配置中
794        let prompts = match app {
795            AppType::Claude => &mut config.prompts.claude.prompts,
796            AppType::Codex => &mut config.prompts.codex.prompts,
797            AppType::Gemini => &mut config.prompts.gemini.prompts,
798            AppType::OpenCode => &mut config.prompts.opencode.prompts,
799            AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
800            AppType::Hermes => &mut config.prompts.hermes.prompts,
801        };
802
803        prompts.insert(id, prompt);
804
805        log::info!("自动导入完成: {}", app.as_str());
806        Ok(true)
807    }
808
809    /// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构
810    ///
811    /// 迁移策略:
812    /// 1. 检查是否已经迁移(mcp.servers 是否存在)
813    /// 2. 收集所有应用的 MCP,按 ID 去重合并
814    /// 3. 生成统一的 McpServer 结构,标记应用到哪些客户端
815    /// 4. 清空旧的分应用配置
816    pub fn migrate_mcp_to_unified(&mut self) -> Result<bool, AppError> {
817        // 检查是否已经是新结构
818        if self.mcp.servers.is_some() {
819            log::debug!("MCP 配置已是统一结构,跳过迁移");
820            return Ok(false);
821        }
822
823        log::info!("检测到旧版 MCP 配置格式,开始迁移到 v3.7.0 统一结构...");
824
825        let mut unified_servers: HashMap<String, McpServer> = HashMap::new();
826        let mut conflicts = Vec::new();
827
828        // 收集所有应用的 MCP
829        for app in [
830            AppType::Claude,
831            AppType::Codex,
832            AppType::Gemini,
833            AppType::OpenCode,
834            AppType::OpenClaw,
835            AppType::Hermes,
836        ] {
837            let old_servers = match app {
838                AppType::Claude => &self.mcp.claude.servers,
839                AppType::Codex => &self.mcp.codex.servers,
840                AppType::Gemini => &self.mcp.gemini.servers,
841                AppType::OpenCode => &self.mcp.opencode.servers,
842                AppType::OpenClaw => &self.mcp.openclaw.servers,
843                AppType::Hermes => &self.mcp.hermes.servers,
844            };
845
846            for (id, entry) in old_servers {
847                let enabled = entry
848                    .get("enabled")
849                    .and_then(|v| v.as_bool())
850                    .unwrap_or(true);
851
852                if let Some(existing) = unified_servers.get_mut(id) {
853                    // 该 ID 已存在,合并 apps 字段
854                    existing.apps.set_enabled_for(&app, enabled);
855
856                    // 检测配置冲突(同 ID 但配置不同)
857                    if existing.server != *entry.get("server").unwrap_or(&serde_json::json!({})) {
858                        conflicts.push(format!(
859                            "MCP '{id}' 在 {} 和之前的应用中配置不同,将使用首次遇到的配置",
860                            app.as_str()
861                        ));
862                    }
863                } else {
864                    // 首次遇到该 MCP,创建新条目
865                    let name = entry
866                        .get("name")
867                        .and_then(|v| v.as_str())
868                        .unwrap_or(id)
869                        .to_string();
870
871                    let server = entry
872                        .get("server")
873                        .cloned()
874                        .unwrap_or(serde_json::json!({}));
875
876                    let description = entry
877                        .get("description")
878                        .and_then(|v| v.as_str())
879                        .map(|s| s.to_string());
880
881                    let homepage = entry
882                        .get("homepage")
883                        .and_then(|v| v.as_str())
884                        .map(|s| s.to_string());
885
886                    let docs = entry
887                        .get("docs")
888                        .and_then(|v| v.as_str())
889                        .map(|s| s.to_string());
890
891                    let tags = entry
892                        .get("tags")
893                        .and_then(|v| v.as_array())
894                        .map(|arr| {
895                            arr.iter()
896                                .filter_map(|v| v.as_str().map(|s| s.to_string()))
897                                .collect()
898                        })
899                        .unwrap_or_default();
900
901                    let mut apps = McpApps::default();
902                    apps.set_enabled_for(&app, enabled);
903
904                    unified_servers.insert(
905                        id.clone(),
906                        McpServer {
907                            id: id.clone(),
908                            name,
909                            server,
910                            apps,
911                            description,
912                            homepage,
913                            docs,
914                            tags,
915                        },
916                    );
917                }
918            }
919        }
920
921        // 记录冲突警告
922        if !conflicts.is_empty() {
923            log::warn!("MCP 迁移过程中检测到配置冲突:");
924            for conflict in &conflicts {
925                log::warn!("  - {conflict}");
926            }
927        }
928
929        log::info!(
930            "MCP 迁移完成,共迁移 {} 个服务器{}",
931            unified_servers.len(),
932            if !conflicts.is_empty() {
933                format!("(存在 {} 个冲突)", conflicts.len())
934            } else {
935                String::new()
936            }
937        );
938
939        // 替换为新结构
940        self.mcp.servers = Some(unified_servers);
941
942        // 清空旧的分应用配置
943        self.mcp.claude = McpConfig::default();
944        self.mcp.codex = McpConfig::default();
945        self.mcp.gemini = McpConfig::default();
946        self.mcp.opencode = McpConfig::default();
947
948        Ok(true)
949    }
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955    use serde_json::json;
956    use serial_test::serial;
957    use std::env;
958    use std::ffi::OsString;
959    use std::fs;
960    use std::path::Path;
961    use tempfile::TempDir;
962
963    struct TempHome {
964        #[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
965        dir: TempDir,
966        _lock: crate::test_support::TestHomeSettingsLock,
967        original_home: Option<OsString>,
968        original_userprofile: Option<OsString>,
969        original_config_dir: Option<OsString>,
970    }
971
972    impl TempHome {
973        fn new() -> Self {
974            let dir = TempDir::new().expect("failed to create temp home");
975            let lock = crate::test_support::lock_test_home_and_settings();
976            let original_home = env::var_os("HOME");
977            let original_userprofile = env::var_os("USERPROFILE");
978            let original_config_dir = env::var_os("CC_SWITCH_CONFIG_DIR");
979
980            unsafe {
981                env::set_var("HOME", dir.path());
982            }
983            unsafe {
984                env::set_var("USERPROFILE", dir.path());
985            }
986            unsafe {
987                env::set_var("CC_SWITCH_CONFIG_DIR", dir.path().join(".cc-switch"));
988            }
989            crate::test_support::set_test_home_override(Some(dir.path()));
990            crate::settings::reload_test_settings();
991
992            Self {
993                dir,
994                _lock: lock,
995                original_home,
996                original_userprofile,
997                original_config_dir,
998            }
999        }
1000    }
1001
1002    impl Drop for TempHome {
1003        fn drop(&mut self) {
1004            match &self.original_home {
1005                Some(value) => unsafe { env::set_var("HOME", value) },
1006                None => unsafe { env::remove_var("HOME") },
1007            }
1008
1009            match &self.original_userprofile {
1010                Some(value) => unsafe { env::set_var("USERPROFILE", value) },
1011                None => unsafe { env::remove_var("USERPROFILE") },
1012            }
1013
1014            match &self.original_config_dir {
1015                Some(value) => unsafe { env::set_var("CC_SWITCH_CONFIG_DIR", value) },
1016                None => unsafe { env::remove_var("CC_SWITCH_CONFIG_DIR") },
1017            }
1018
1019            crate::test_support::set_test_home_override(
1020                self.original_home.as_deref().map(Path::new),
1021            );
1022            crate::settings::reload_test_settings();
1023        }
1024    }
1025
1026    fn write_prompt_file(app: AppType, content: &str) {
1027        let path = crate::prompt_files::prompt_file_path(&app).expect("prompt path");
1028        if let Some(parent) = path.parent() {
1029            fs::create_dir_all(parent).expect("create parent dir");
1030        }
1031        fs::write(path, content).expect("write prompt");
1032    }
1033
1034    fn seed_stale_test_home_with_gemini_override(home: &std::path::Path) {
1035        let stale_gemini_dir = home.join("custom-gemini");
1036        let _lock = crate::test_support::lock_test_home_and_settings();
1037        let original_config_dir = env::var_os("CC_SWITCH_CONFIG_DIR");
1038
1039        unsafe {
1040            env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
1041        }
1042        crate::test_support::set_test_home_override(Some(home));
1043        crate::settings::reload_test_settings();
1044
1045        let mut settings = crate::settings::AppSettings::default();
1046        settings.gemini_config_dir = Some(stale_gemini_dir.to_string_lossy().into_owned());
1047        settings.save().expect("save stale settings");
1048        crate::settings::reload_test_settings();
1049
1050        match original_config_dir {
1051            Some(value) => unsafe { env::set_var("CC_SWITCH_CONFIG_DIR", value) },
1052            None => unsafe { env::remove_var("CC_SWITCH_CONFIG_DIR") },
1053        }
1054    }
1055
1056    #[test]
1057    #[serial(home_settings)]
1058    fn temp_home_refreshes_test_home_override_and_settings_cache() {
1059        let stale_home = TempDir::new().expect("failed to create stale home");
1060        seed_stale_test_home_with_gemini_override(stale_home.path());
1061
1062        let home = TempHome::new();
1063
1064        assert_eq!(
1065            crate::config::home_dir(),
1066            Some(home.dir.path().to_path_buf())
1067        );
1068        assert_eq!(crate::settings::get_gemini_override_dir(), None);
1069        assert_eq!(
1070            crate::prompt_files::prompt_file_path(&AppType::Gemini).expect("gemini prompt path"),
1071            home.dir.path().join(".gemini").join("GEMINI.md")
1072        );
1073    }
1074
1075    #[test]
1076    #[serial]
1077    fn auto_imports_existing_prompt_when_config_missing() {
1078        let _home = TempHome::new();
1079        write_prompt_file(AppType::Claude, "# hello");
1080
1081        let config = MultiAppConfig::load().expect("load config");
1082
1083        assert_eq!(config.prompts.claude.prompts.len(), 1);
1084        let prompt = config
1085            .prompts
1086            .claude
1087            .prompts
1088            .values()
1089            .next()
1090            .expect("prompt exists");
1091        assert!(prompt.enabled);
1092        assert_eq!(prompt.content, "# hello");
1093
1094        let config_path = crate::config::get_app_config_path();
1095        assert!(
1096            config_path.exists(),
1097            "auto import should persist config to disk"
1098        );
1099    }
1100
1101    #[test]
1102    #[serial]
1103    fn skips_empty_prompt_files_during_import() {
1104        let _home = TempHome::new();
1105        write_prompt_file(AppType::Claude, "   \n  ");
1106
1107        let config = MultiAppConfig::load().expect("load config");
1108        assert!(
1109            config.prompts.claude.prompts.is_empty(),
1110            "empty files must be ignored"
1111        );
1112    }
1113
1114    #[test]
1115    #[serial]
1116    fn auto_import_happens_only_once() {
1117        let _home = TempHome::new();
1118        write_prompt_file(AppType::Claude, "first version");
1119
1120        let first = MultiAppConfig::load().expect("load config");
1121        assert_eq!(first.prompts.claude.prompts.len(), 1);
1122        let claude_prompt = first
1123            .prompts
1124            .claude
1125            .prompts
1126            .values()
1127            .next()
1128            .expect("prompt exists")
1129            .content
1130            .clone();
1131        assert_eq!(claude_prompt, "first version");
1132
1133        // 覆盖文件内容,但保留 config.json
1134        write_prompt_file(AppType::Claude, "second version");
1135        let second = MultiAppConfig::load().expect("load config again");
1136
1137        assert_eq!(second.prompts.claude.prompts.len(), 1);
1138        let prompt = second
1139            .prompts
1140            .claude
1141            .prompts
1142            .values()
1143            .next()
1144            .expect("prompt exists");
1145        assert_eq!(
1146            prompt.content, "first version",
1147            "should not re-import when config already exists"
1148        );
1149    }
1150
1151    #[test]
1152    #[serial]
1153    fn auto_imports_gemini_prompt_on_first_launch() {
1154        let _home = TempHome::new();
1155        write_prompt_file(AppType::Gemini, "# Gemini Prompt\n\nTest content");
1156
1157        let config = MultiAppConfig::load().expect("load config");
1158
1159        assert_eq!(config.prompts.gemini.prompts.len(), 1);
1160        let prompt = config
1161            .prompts
1162            .gemini
1163            .prompts
1164            .values()
1165            .next()
1166            .expect("gemini prompt exists");
1167        assert!(prompt.enabled, "gemini prompt should be enabled");
1168        assert_eq!(prompt.content, "# Gemini Prompt\n\nTest content");
1169        assert_eq!(
1170            prompt.description,
1171            Some("Automatically imported on first launch".to_string())
1172        );
1173    }
1174
1175    #[test]
1176    #[serial]
1177    fn auto_imports_all_three_apps_prompts() {
1178        let _home = TempHome::new();
1179        write_prompt_file(AppType::Claude, "# Claude prompt");
1180        write_prompt_file(AppType::Codex, "# Codex prompt");
1181        write_prompt_file(AppType::Gemini, "# Gemini prompt");
1182
1183        let config = MultiAppConfig::load().expect("load config");
1184
1185        // 验证所有三个应用的提示词都被导入
1186        assert_eq!(config.prompts.claude.prompts.len(), 1);
1187        assert_eq!(config.prompts.codex.prompts.len(), 1);
1188        assert_eq!(config.prompts.gemini.prompts.len(), 1);
1189
1190        // 验证所有提示词都被启用
1191        assert!(
1192            config
1193                .prompts
1194                .claude
1195                .prompts
1196                .values()
1197                .next()
1198                .unwrap()
1199                .enabled
1200        );
1201        assert!(
1202            config
1203                .prompts
1204                .codex
1205                .prompts
1206                .values()
1207                .next()
1208                .unwrap()
1209                .enabled
1210        );
1211        assert!(
1212            config
1213                .prompts
1214                .gemini
1215                .prompts
1216                .values()
1217                .next()
1218                .unwrap()
1219                .enabled
1220        );
1221    }
1222
1223    #[test]
1224    fn migrate_mcp_to_unified_imports_openclaw_legacy_servers() {
1225        let mut config = MultiAppConfig::default();
1226        config.mcp.servers = None;
1227        config.mcp.claude.servers.insert(
1228            "claude-tool".to_string(),
1229            json!({
1230                "enabled": true,
1231                "name": "Claude Tool",
1232                "server": {"command": "claude-tool"}
1233            }),
1234        );
1235        config.mcp.openclaw.servers.insert(
1236            "openclaw-tool".to_string(),
1237            json!({
1238                "enabled": true,
1239                "name": "OpenClaw Tool",
1240                "server": {"command": "openclaw-tool"}
1241            }),
1242        );
1243
1244        let migrated = config
1245            .migrate_mcp_to_unified()
1246            .expect("mcp migration should succeed");
1247
1248        assert!(migrated);
1249        let unified = config
1250            .mcp
1251            .servers
1252            .as_ref()
1253            .expect("unified servers should exist");
1254        assert!(unified.contains_key("claude-tool"));
1255        assert!(
1256            unified
1257                .get("openclaw-tool")
1258                .is_some_and(|server| server.apps.openclaw),
1259            "OpenClaw legacy MCP entries should migrate into unified storage"
1260        );
1261    }
1262}