mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
8f018a2d45
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
383 lines
15 KiB
Rust
383 lines
15 KiB
Rust
//! 项目 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(¤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<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(¤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());
|
||
}
|
||
}
|