//! 项目 Profile 编排服务 //! //! Profile 是一份按 app 的配置快照(供应商 / MCP / Skills / Prompt), //! 应用(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; /// P1 支持的应用范围(扩展新 app 时同步扩展 PerApp 字段) pub const PROFILE_APPS: [AppType; 2] = [AppType::Claude, AppType::Codex]; /// 按 app 分槽的载荷容器;字段名与 AppType 的 serde 小写形式一致 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct PerApp { pub claude: T, pub codex: T, } impl PerApp { pub fn get(&self, app: &AppType) -> Option<&T> { match app { AppType::Claude => Some(&self.claude), 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::Codex => Some(&mut self.codex), _ => None, } } } /// Profile 的 JSON 快照结构(与前端 TS 类型严格对应) #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ProfilePayload { /// 每 app 的当前供应商 id(None = 快照时无当前供应商,应用时不动) pub providers: PerApp>, /// 每 app 启用的 MCP server id 集合 pub mcp: PerApp>, /// 每 app 启用的 Skill id 集合 pub skills: PerApp>, /// 每 app 激活的 prompt id(None = 快照时无激活项,应用时不动) pub prompts: PerApp>, } /// 计算从当前启用状态到目标集合的最小 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) -> 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 PROFILE_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 = 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 = 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) } /// 列出所有项目及当前激活项目 id pub fn list(state: &AppState) -> Result<(Vec, Option), AppError> { let profiles = state.db.get_all_profiles()?; let current = state.db.get_current_profile_id()?; Ok((profiles, current)) } /// 以当前配置状态创建新项目 pub fn create(state: &AppState, name: &str) -> 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)?; 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) } /// 更新项目:重命名和/或以当前状态重拍快照 pub fn update( state: &AppState, id: &str, name: Option, resnapshot: bool, ) -> 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 payload = Self::snapshot_current(state)?; 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)?; if state.db.get_current_profile_id()?.as_deref() == Some(id) { state.db.set_current_profile_id(None)?; } Ok(()) } /// 应用项目快照(best-effort,返回 warnings) /// /// 顺序不可换:供应商切换(switch_normal 内部会按 DB 当前标志跑 MCP /// sync_all_enabled)必须先于 MCP diff,否则 profile 的 MCP 目标态会被冲掉。 pub fn apply(state: &AppState, profile_id: &str) -> Result, AppError> { 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(); for app in PROFILE_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:仅动目标态≠当前态的条目) if let 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(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(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()), codex: None, }, mcp: PerApp { claude: ids(&["m1", "m2"]), codex: vec![], }, skills: PerApp { claude: vec![], codex: ids(&["s1"]), }, prompts: PerApp { claude: None, codex: Some("pr1".into()), }, }; let json = serde_json::to_string(&payload).unwrap(); // per-app key 必须是 AppType serde 的小写形式 assert!(json.contains("\"claude\"")); assert!(json.contains("\"codex\"")); let back: ProfilePayload = serde_json::from_str(&json).unwrap(); assert_eq!(back, payload); } #[test] fn test_payload_tolerates_missing_fields() { // 前向兼容:旧版/部分字段缺失时应落到默认值而不是报错 let back: ProfilePayload = serde_json::from_str(r#"{"providers":{"claude":"p1"}}"#).unwrap(); assert_eq!(back.providers.claude, Some("p1".to_string())); assert_eq!(back.providers.codex, None); assert!(back.mcp.claude.is_empty()); assert_eq!(back.prompts.codex, None); let empty: ProfilePayload = serde_json::from_str("{}").unwrap(); assert_eq!(empty, ProfilePayload::default()); } #[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::Codex).is_some()); assert!(per.get(&AppType::Gemini).is_none()); assert!(per.get(&AppType::ClaudeDesktop).is_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()); } }