//! 项目 Profile 编排服务 //! //! Profile 是**全应用共享的项目实体**(用户拥有的项目就那几个),payload //! 按 app 分槽存配置快照(供应商 / MCP / Skills / Prompt)。快照与应用 //! 均**按分组(scope)操作**:Claude Code 与 Codex 的工作目录往往不同 //! (各在各的项目里),因此各组独立指向自己的当前项目、只拍/只应用组内 //! 槽位,互不牵连;重命名/删除作用于共享实体本身。 //! 应用(apply)时复用现有切换原语批量落地: //! - 供应商:`ProviderService::switch`(内建代理接管热切换与接管下禁切官方) //! - MCP:`McpService::toggle_app`(改标志 + 单 server 物化) //! - Skills:`SkillService::toggle_app`(改标志 + 单 skill 物化) //! - Prompt:`PromptService::enable_prompt`(互斥激活 + 原子写 live) //! //! apply 为 best-effort:单项失败收集为 warning 继续,不整体回滚。 use std::collections::HashSet; use serde::{Deserialize, Serialize}; use crate::app_config::AppType; use crate::database::Profile; use crate::error::AppError; use crate::services::{McpService, PromptService, ProviderService, SkillService}; use crate::store::AppState; /// Profile 操作的应用分组:项目实体全应用共享,但快照/应用/当前指针按组进行 /// /// Claude Desktop 并入 Claude 组:它在 cc-switch 里只有聊天侧供应商一个 /// 受管维度,且其内嵌 Code 标签页读的就是 Claude Code 的那套 live 文件 /// (`~/.claude/*`,天然跟随),不足以构成独立的"项目"维度。 /// 供应商 live 文件两组零交集(`~/.claude` / `~/.codex` / /// `Application Support/Claude-3p`),分组切换互不干扰。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProfileScope { Claude, Codex, } impl ProfileScope { /// 全部分组(扩展新分组时同步扩展 apps/for_app 与前端 scope.ts 镜像) pub const ALL: [ProfileScope; 2] = [ProfileScope::Claude, ProfileScope::Codex]; pub fn as_str(&self) -> &'static str { match self { ProfileScope::Claude => "claude", ProfileScope::Codex => "codex", } } pub fn parse(value: &str) -> Result { match value { "claude" => Ok(ProfileScope::Claude), "codex" => Ok(ProfileScope::Codex), other => Err(AppError::InvalidInput(format!( "Unknown profile scope: {other}" ))), } } /// 组内受管应用(快照与 apply 只作用于这些 app 的槽位) /// /// Claude Desktop 只有供应商一个活跃维度:MCP/Skills 的 `is_enabled_for` /// 对它恒为 false(快照天然为空集)、prompt 无 live 文件(快照为 None), /// apply 时空集 diff 与 None 都是 no-op,无需按维度特判。 pub fn apps(&self) -> &'static [AppType] { match self { ProfileScope::Claude => &[AppType::Claude, AppType::ClaudeDesktop], ProfileScope::Codex => &[AppType::Codex], } } /// 应用页 → 所属分组(Profile 不支持的应用返回 None) pub fn for_app(app: &AppType) -> Option { match app { AppType::Claude | AppType::ClaudeDesktop => Some(ProfileScope::Claude), AppType::Codex => Some(ProfileScope::Codex), _ => None, } } } /// 按 app 分槽的载荷容器;字段名与 AppType 的 serde 形式一致 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct PerApp { pub claude: T, #[serde(rename = "claude-desktop")] pub claude_desktop: T, pub codex: T, } impl PerApp { pub fn get(&self, app: &AppType) -> Option<&T> { match app { AppType::Claude => Some(&self.claude), AppType::ClaudeDesktop => Some(&self.claude_desktop), AppType::Codex => Some(&self.codex), _ => None, } } pub fn get_mut(&mut self, app: &AppType) -> Option<&mut T> { match app { AppType::Claude => Some(&mut self.claude), AppType::ClaudeDesktop => Some(&mut self.claude_desktop), AppType::Codex => Some(&mut self.codex), _ => None, } } } /// Profile 的 JSON 快照结构(与前端 TS 类型严格对应) /// /// 所有槽位都是 Option:None = 该侧从未拍过快照(应用时不动), /// 与"拍到的就是空集/无激活项"(Some(空),应用时清空启用)严格区分—— /// 在 Codex 页选中一个只在 Claude 页建过的项目不能误清 Codex 的启用状态。 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ProfilePayload { /// 每 app 的当前供应商 id pub providers: PerApp>, /// 每 app 启用的 MCP server id 集合 pub mcp: PerApp>>, /// 每 app 启用的 Skill id 集合 pub skills: PerApp>>, /// 每 app 激活的 prompt id pub prompts: PerApp>, } impl ProfilePayload { /// 用另一份快照覆盖本载荷中某分组的槽位,其余分组原样保留 /// ("以当前状态更新"只更新发起页所属分组,避免把别的应用 /// 正处于其他项目的状态串进来) pub fn merge_scope_from(&mut self, other: &ProfilePayload, scope: ProfileScope) { for app in scope.apps() { if let (Some(dst), Some(src)) = (self.providers.get_mut(app), other.providers.get(app)) { *dst = src.clone(); } if let (Some(dst), Some(src)) = (self.mcp.get_mut(app), other.mcp.get(app)) { *dst = src.clone(); } if let (Some(dst), Some(src)) = (self.skills.get_mut(app), other.skills.get(app)) { *dst = src.clone(); } if let (Some(dst), Some(src)) = (self.prompts.get_mut(app), other.prompts.get(app)) { *dst = src.clone(); } } } /// 某分组是否拍过快照(任一槽位非 None 即视为拍过) pub fn scope_captured(&self, scope: ProfileScope) -> bool { scope.apps().iter().any(|app| { self.providers.get(app).is_some_and(|s| s.is_some()) || self.mcp.get(app).is_some_and(|s| s.is_some()) || self.skills.get(app).is_some_and(|s| s.is_some()) || self.prompts.get(app).is_some_and(|s| s.is_some()) }) } } /// 计算从当前启用状态到目标集合的最小 toggle 集 /// /// 返回 (需要执行的 (id, enabled) 列表, payload 中已不存在于 DB 的悬空 id 列表) fn plan_toggles( current: &[(String, bool)], target_ids: &[String], ) -> (Vec<(String, bool)>, Vec) { let existing: HashSet<&str> = current.iter().map(|(id, _)| id.as_str()).collect(); let target: HashSet<&str> = target_ids.iter().map(|s| s.as_str()).collect(); let toggles = current .iter() .filter(|(id, enabled)| target.contains(id.as_str()) != *enabled) .map(|(id, enabled)| (id.clone(), !enabled)) .collect(); let dangling = target_ids .iter() .filter(|id| !existing.contains(id.as_str())) .cloned() .collect(); (toggles, dangling) } pub struct ProfileService; impl ProfileService { /// 抓取分组内应用的当前配置状态生成快照(组外槽位保持默认值) pub fn snapshot_current( state: &AppState, scope: ProfileScope, ) -> Result { let mut payload = ProfilePayload::default(); let mcp_servers = state.db.get_all_mcp_servers()?; let skills = state.db.get_all_installed_skills()?; for app in scope.apps().iter() { if let Some(slot) = payload.providers.get_mut(app) { *slot = crate::settings::get_effective_current_provider(&state.db, app)?; } if let Some(slot) = payload.mcp.get_mut(app) { *slot = Some( mcp_servers .values() .filter(|s| s.apps.is_enabled_for(app)) .map(|s| s.id.clone()) .collect(), ); } if let Some(slot) = payload.skills.get_mut(app) { *slot = Some( skills .values() .filter(|s| s.apps.is_enabled_for(app)) .map(|s| s.id.clone()) .collect(), ); } if let Some(slot) = payload.prompts.get_mut(app) { *slot = state .db .get_prompts(app.as_str())? .values() .find(|p| p.enabled) .map(|p| p.id.clone()); } } Ok(payload) } /// 列出所有项目(项目实体全应用共享,current 标记按分组单独读取) pub fn list(state: &AppState) -> Result, AppError> { state.db.get_all_profiles() } /// 创建新项目:只拍发起页所属分组的当前状态,其余分组槽位留 None /// (其他应用可能正处于别的项目,不能替用户拍进来) pub fn create(state: &AppState, name: &str, scope: ProfileScope) -> Result { let name = name.trim(); if name.is_empty() { return Err(AppError::InvalidInput("Profile name is empty".to_string())); } let payload = Self::snapshot_current(state, scope)?; let now = chrono::Utc::now().timestamp(); let profile = Profile { id: uuid::Uuid::new_v4().to_string(), name: name.to_string(), payload: serde_json::to_string(&payload) .map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?, sort_order: None, created_at: Some(now), updated_at: Some(now), }; state.db.save_profile(&profile)?; Ok(profile) } /// 更新项目:重命名(作用于共享实体)和/或以当前状态重拍快照 /// (resnapshot 只覆盖 scope 分组的槽位,其余分组原样保留) pub fn update( state: &AppState, id: &str, name: Option, resnapshot: bool, scope: Option, ) -> Result { let mut profile = state .db .get_profile(id)? .ok_or_else(|| AppError::InvalidInput(format!("Profile not found: {id}")))?; if let Some(name) = name { let name = name.trim().to_string(); if name.is_empty() { return Err(AppError::InvalidInput("Profile name is empty".to_string())); } profile.name = name; } if resnapshot { let scope = scope.ok_or_else(|| { AppError::InvalidInput("Resnapshot requires a profile scope".to_string()) })?; let mut payload: ProfilePayload = serde_json::from_str(&profile.payload) .map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?; payload.merge_scope_from(&Self::snapshot_current(state, scope)?, scope); profile.payload = serde_json::to_string(&payload) .map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?; } profile.updated_at = Some(chrono::Utc::now().timestamp()); state.db.save_profile(&profile)?; Ok(profile) } /// 删除项目;若删除的是某分组当前激活项目,一并清除该分组的激活标记 pub fn delete(state: &AppState, id: &str) -> Result<(), AppError> { state.db.delete_profile(id)?; for scope in ProfileScope::ALL { if state.db.get_current_profile_id(scope.as_str())?.as_deref() == Some(id) { state.db.set_current_profile_id(scope.as_str(), None)?; } } Ok(()) } /// 应用项目快照(best-effort,返回 warnings) /// /// 只作用于发起页所属分组内的应用,不碰其他分组的配置与 current 标记。 /// 该分组从未拍过快照时不改动任何配置,仅标记 current 并返回提示 /// (用户可先绑定项目、再"以当前状态更新"补拍该侧快照)。 /// /// **切换前会自动保存旧项目**:若当前分组已绑定到另一个项目,先把当前 /// 状态写入那个旧项目(仅当前分组槽位),再加载目标项目。这样切走后 /// 旧项目仍保留离开时的配置,回来时状态一致。自动保存失败时作为 warning /// 继续,不阻塞切换。 /// /// 顺序不可换:供应商切换(switch_normal 内部会按 DB 当前标志跑 MCP /// sync_all_enabled)必须先于 MCP diff,否则 profile 的 MCP 目标态会被冲掉。 pub fn apply( state: &AppState, profile_id: &str, scope: ProfileScope, ) -> Result, AppError> { let mut warnings = Vec::new(); // 自动保存旧项目当前状态(仅当前分组),失败不阻塞切换 if let Some(current_id) = state.db.get_current_profile_id(scope.as_str())? { if current_id != profile_id { if let Err(e) = Self::update(state, ¤t_id, None, true, Some(scope)) { warnings.push(format!( "autosave profile '{current_id}' before switch failed: {e}" )); } } } let profile = state .db .get_profile(profile_id)? .ok_or_else(|| AppError::InvalidInput(format!("Profile not found: {profile_id}")))?; let payload: ProfilePayload = serde_json::from_str(&profile.payload) .map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?; let mut warnings = Vec::new(); if !payload.scope_captured(scope) { warnings.push(format!( "no {} configuration captured in this project yet; marked as current without changes", scope.as_str() )); } for app in scope.apps().iter() { let app_str = app.as_str(); // 1. 供应商 if let Some(Some(target_pid)) = payload.providers.get(app) { let providers = state.db.get_all_providers(app_str)?; if !providers.contains_key(target_pid) { warnings.push(format!( "[{app_str}] provider '{target_pid}' no longer exists, skipped" )); } else { let current = crate::settings::get_effective_current_provider(&state.db, app)?; if current.as_deref() != Some(target_pid.as_str()) { match ProviderService::switch(state, app.clone(), target_pid) { Ok(result) => warnings.extend(result.warnings), Err(e) => warnings.push(format!( "[{app_str}] switch provider '{target_pid}' failed: {e}" )), } } } } // 2. MCP diff(最小 toggle:仅动目标态≠当前态的条目;None = 该侧未拍过,不动) if let Some(Some(target_ids)) = payload.mcp.get(app) { let servers = state.db.get_all_mcp_servers()?; let current: Vec<(String, bool)> = servers .values() .map(|s| (s.id.clone(), s.apps.is_enabled_for(app))) .collect(); let (toggles, dangling) = plan_toggles(¤t, target_ids); for id in dangling { warnings.push(format!("[{app_str}] MCP '{id}' no longer exists, skipped")); } for (id, enabled) in toggles { if let Err(e) = McpService::toggle_app(state, &id, app.clone(), enabled) { warnings.push(format!( "[{app_str}] toggle MCP '{id}' -> {enabled} failed: {e}" )); } } } // 3. Skills diff(SkillService 返回 anyhow::Result,收进 warning) if let Some(Some(target_ids)) = payload.skills.get(app) { let skills = state.db.get_all_installed_skills()?; let current: Vec<(String, bool)> = skills .values() .map(|s| (s.id.clone(), s.apps.is_enabled_for(app))) .collect(); let (toggles, dangling) = plan_toggles(¤t, target_ids); for id in dangling { warnings.push(format!( "[{app_str}] skill '{id}' no longer exists, skipped" )); } for (id, enabled) in toggles { if let Err(e) = SkillService::toggle_app(&state.db, &id, app, enabled) { warnings.push(format!( "[{app_str}] toggle skill '{id}' -> {enabled} failed: {e}" )); } } } // 4. Prompt(None = 不动;已激活则幂等跳过,避免无谓的文件写与备份) if let Some(Some(target_prompt)) = payload.prompts.get(app) { let prompts = state.db.get_prompts(app_str)?; match prompts.get(target_prompt) { None => warnings.push(format!( "[{app_str}] prompt '{target_prompt}' no longer exists, skipped" )), Some(p) if p.enabled => {} Some(_) => { if let Err(e) = PromptService::enable_prompt(state, app.clone(), target_prompt) { warnings.push(format!( "[{app_str}] enable prompt '{target_prompt}' failed: {e}" )); } } } } } state .db .set_current_profile_id(scope.as_str(), Some(profile_id))?; Ok(warnings) } } #[cfg(test)] mod tests { use super::*; fn ids(v: &[&str]) -> Vec { v.iter().map(|s| s.to_string()).collect() } #[test] fn test_payload_serde_roundtrip() { let payload = ProfilePayload { providers: PerApp { claude: Some("p1".into()), claude_desktop: Some("d1".into()), codex: None, }, mcp: PerApp { claude: Some(ids(&["m1", "m2"])), claude_desktop: Some(vec![]), codex: None, }, skills: PerApp { claude: Some(vec![]), claude_desktop: Some(vec![]), codex: Some(ids(&["s1"])), }, prompts: PerApp { claude: None, claude_desktop: None, codex: Some("pr1".into()), }, }; let json = serde_json::to_string(&payload).unwrap(); // per-app key 必须与 AppType 的 serde 形式一致(claude-desktop 是连字符) assert!(json.contains("\"claude\"")); assert!(json.contains("\"claude-desktop\"")); assert!(json.contains("\"codex\"")); let back: ProfilePayload = serde_json::from_str(&json).unwrap(); assert_eq!(back, payload); } #[test] fn test_payload_tolerates_missing_fields() { // 前向兼容:旧版/部分字段缺失时应落到 None("该侧未拍过")而不是报错, // 应用时对缺失槽位不做任何改动 let back: ProfilePayload = serde_json::from_str(r#"{"providers":{"claude":"p1"},"mcp":{"claude":["m1"]}}"#) .unwrap(); assert_eq!(back.providers.claude, Some("p1".to_string())); assert_eq!(back.providers.claude_desktop, None); assert_eq!(back.providers.codex, None); assert_eq!(back.mcp.claude, Some(ids(&["m1"]))); assert_eq!(back.mcp.claude_desktop, None); assert_eq!(back.mcp.codex, None, "missing slot means untouched"); assert_eq!(back.prompts.codex, None); let empty: ProfilePayload = serde_json::from_str("{}").unwrap(); assert_eq!(empty, ProfilePayload::default()); } #[test] fn test_merge_scope_from_only_touches_scope_slots() { // 项目 A:两侧都已拍过快照 let mut payload = ProfilePayload { providers: PerApp { claude: Some("p1".into()), claude_desktop: Some("d1".into()), codex: Some("c1".into()), }, mcp: PerApp { claude: Some(ids(&["m1"])), claude_desktop: Some(vec![]), codex: Some(ids(&["m9"])), }, ..Default::default() }; // 在 Claude 页"以当前状态更新":只覆盖 claude 组槽位 let fresh = ProfilePayload { providers: PerApp { claude: Some("p2".into()), claude_desktop: None, codex: Some("SHOULD-NOT-LEAK".into()), }, mcp: PerApp { claude: Some(ids(&["m2"])), claude_desktop: Some(vec![]), codex: None, }, ..Default::default() }; payload.merge_scope_from(&fresh, ProfileScope::Claude); assert_eq!(payload.providers.claude, Some("p2".to_string())); assert_eq!(payload.providers.claude_desktop, None); assert_eq!(payload.mcp.claude, Some(ids(&["m2"]))); // codex 侧完好:既没被覆盖也没被 fresh 的值污染 assert_eq!(payload.providers.codex, Some("c1".to_string())); assert_eq!(payload.mcp.codex, Some(ids(&["m9"]))); } #[test] fn test_scope_captured_detects_per_scope_snapshot() { let mut payload = ProfilePayload::default(); assert!(!payload.scope_captured(ProfileScope::Claude)); assert!(!payload.scope_captured(ProfileScope::Codex)); // 只拍过 claude 组(哪怕拍到的是空集) payload.mcp.claude = Some(vec![]); assert!(payload.scope_captured(ProfileScope::Claude)); assert!(!payload.scope_captured(ProfileScope::Codex)); // Desktop 槽位属于 claude 组 let mut desktop_only = ProfilePayload::default(); desktop_only.providers.claude_desktop = Some("d1".into()); assert!(desktop_only.scope_captured(ProfileScope::Claude)); } #[test] fn test_per_app_get_only_supports_profile_apps() { let per: PerApp> = PerApp::default(); assert!(per.get(&AppType::Claude).is_some()); assert!(per.get(&AppType::ClaudeDesktop).is_some()); assert!(per.get(&AppType::Codex).is_some()); assert!(per.get(&AppType::Gemini).is_none()); } #[test] fn test_scope_serde_and_parse_roundtrip() { for scope in ProfileScope::ALL { // DB 存储字符串(as_str/parse)与 JSON 序列化必须是同一形式 assert_eq!( serde_json::to_string(&scope).unwrap(), format!("\"{}\"", scope.as_str()) ); assert_eq!(ProfileScope::parse(scope.as_str()).unwrap(), scope); } assert!(ProfileScope::parse("gemini").is_err()); assert!(ProfileScope::parse("").is_err()); } #[test] fn test_scope_app_grouping() { // Desktop 并入 Claude 组;组内应用与 for_app 反向映射必须一致 assert_eq!( ProfileScope::Claude.apps(), &[AppType::Claude, AppType::ClaudeDesktop] ); assert_eq!(ProfileScope::Codex.apps(), &[AppType::Codex]); for scope in ProfileScope::ALL { for app in scope.apps() { assert_eq!(ProfileScope::for_app(app), Some(scope)); } } assert_eq!(ProfileScope::for_app(&AppType::Gemini), None); } #[test] fn test_plan_toggles_minimal_diff() { let current = vec![ ("a".to_string(), true), // 目标含 a:不动 ("b".to_string(), false), // 目标含 b:开 ("c".to_string(), true), // 目标不含 c:关 ("d".to_string(), false), // 目标不含 d:不动 ]; let (toggles, dangling) = plan_toggles(¤t, &ids(&["a", "b", "ghost"])); assert_eq!( toggles, vec![("b".to_string(), true), ("c".to_string(), false)] ); assert_eq!(dangling, ids(&["ghost"])); } #[test] fn test_plan_toggles_empty_target_disables_all_enabled() { let current = vec![("a".to_string(), true), ("b".to_string(), false)]; let (toggles, dangling) = plan_toggles(¤t, &[]); assert_eq!(toggles, vec![("a".to_string(), false)]); assert!(dangling.is_empty()); } }