Files
CC-Switch/src-tauri/src/services/profile.rs
T
Jason 8f018a2d45 feat: add project profiles for snapshot-based config switching
Add a profile feature that captures the current provider, MCP,
skills and prompt state for Claude Code and Codex as a named
snapshot, and re-applies it in one click from the header switcher
or the tray Projects submenu.

- New profiles table (schema v12) with current marker in settings
- ProfileService orchestrates the four existing switch primitives
  (provider first, then MCP diff, skills diff, prompt enable)
- Best-effort apply: dangling references become warnings, no rollback
- Header combobox switcher + snapshot-style manage dialog
- Tray Projects submenu shared with the UI apply/event pipeline
- i18n for zh/en/ja/zh-TW under the new profiles domain
- Integration tests covering roundtrip, dangling refs and clear
2026-07-07 10:49:32 +08:00

383 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 项目 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<T> {
pub claude: T,
pub codex: T,
}
impl<T> PerApp<T> {
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<Option<String>>,
/// 每 app 启用的 MCP server id 集合
pub mcp: PerApp<Vec<String>>,
/// 每 app 启用的 Skill id 集合
pub skills: PerApp<Vec<String>>,
/// 每 app 激活的 prompt id(None = 快照时无激活项,应用时不动)
pub prompts: PerApp<Option<String>>,
}
/// 计算从当前启用状态到目标集合的最小 toggle 集
///
/// 返回 (需要执行的 (id, enabled) 列表, payload 中已不存在于 DB 的悬空 id 列表)
fn plan_toggles(
current: &[(String, bool)],
target_ids: &[String],
) -> (Vec<(String, bool)>, Vec<String>) {
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<ProfilePayload, AppError> {
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<Profile>, Option<String>), 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<Profile, AppError> {
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<String>,
resnapshot: bool,
) -> Result<Profile, AppError> {
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<Vec<String>, 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(&current, 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 diffSkillService 返回 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(&current, 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. PromptNone = 不动;已激活则幂等跳过,避免无谓的文件写与备份)
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<String> {
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<Option<String>> = 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(&current, &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(&current, &[]);
assert_eq!(toggles, vec![("a".to_string(), false)]);
assert!(dangling.is_empty());
}
}