mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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
This commit is contained in:
@@ -18,6 +18,7 @@ mod model_fetch;
|
||||
mod omo;
|
||||
mod openclaw;
|
||||
mod plugin;
|
||||
mod profile;
|
||||
mod prompt;
|
||||
mod provider;
|
||||
mod proxy;
|
||||
@@ -52,6 +53,7 @@ pub use model_fetch::*;
|
||||
pub use omo::*;
|
||||
pub use openclaw::*;
|
||||
pub use plugin::*;
|
||||
pub use profile::*;
|
||||
pub use prompt::*;
|
||||
pub use provider::*;
|
||||
pub use proxy::*;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! 项目 Profile 管理命令
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::database::Profile;
|
||||
use crate::services::profile::{ProfilePayload, ProfileService, PROFILE_APPS};
|
||||
use crate::store::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub payload: ProfilePayload,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<Profile> for ProfileDto {
|
||||
fn from(profile: Profile) -> Self {
|
||||
// 单条 payload 损坏不应拖垮整个列表:降级为默认值并记日志
|
||||
let payload = serde_json::from_str(&profile.payload).unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"解析 profile '{}' payload 失败,使用默认值: {e}",
|
||||
profile.id
|
||||
);
|
||||
ProfilePayload::default()
|
||||
});
|
||||
Self {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
payload,
|
||||
created_at: profile.created_at,
|
||||
updated_at: profile.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfilesResponse {
|
||||
pub profiles: Vec<ProfileDto>,
|
||||
pub current_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Profile 应用完成后的统一收尾:发事件 + 重建托盘菜单
|
||||
///
|
||||
/// UI 与托盘两个入口必须共用此函数,保证事件 payload 形状一致
|
||||
/// (前端 App.tsx 的 provider-switched 监听依赖该形状)。
|
||||
pub fn emit_profile_apply_events(app: &tauri::AppHandle, state: &AppState, profile_id: &str) {
|
||||
for app_type in PROFILE_APPS.iter() {
|
||||
let app_str = app_type.as_str();
|
||||
let (proxy_enabled, auto_failover_enabled) = state.db.get_proxy_flags_sync(app_str);
|
||||
let provider_id = crate::settings::get_effective_current_provider(&state.db, app_type)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let event_data = serde_json::json!({
|
||||
"appType": app_str,
|
||||
"proxyEnabled": proxy_enabled,
|
||||
"autoFailoverEnabled": auto_failover_enabled,
|
||||
"providerId": provider_id,
|
||||
});
|
||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||
log::error!("发射 provider-switched 事件失败: {e}");
|
||||
}
|
||||
}
|
||||
if let Err(e) = app.emit(
|
||||
"profile-applied",
|
||||
serde_json::json!({ "profileId": profile_id }),
|
||||
) {
|
||||
log::error!("发射 profile-applied 事件失败: {e}");
|
||||
}
|
||||
crate::tray::refresh_tray_menu(app);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_profiles(state: State<'_, AppState>) -> Result<ProfilesResponse, String> {
|
||||
let (profiles, current_id) = ProfileService::list(&state).map_err(|e| e.to_string())?;
|
||||
Ok(ProfilesResponse {
|
||||
profiles: profiles.into_iter().map(ProfileDto::from).collect(),
|
||||
current_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_profile(state: State<'_, AppState>, name: String) -> Result<ProfileDto, String> {
|
||||
ProfileService::create(&state, &name)
|
||||
.map(ProfileDto::from)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
resnapshot: Option<bool>,
|
||||
) -> Result<ProfileDto, String> {
|
||||
ProfileService::update(&state, &id, name, resnapshot.unwrap_or(false))
|
||||
.map(ProfileDto::from)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_profile(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
ProfileService::delete(&state, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_current_profile(state: State<'_, AppState>) -> Result<(), String> {
|
||||
state
|
||||
.db
|
||||
.set_current_profile_id(None)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 应用项目快照。
|
||||
///
|
||||
/// 注意:必须保持同步命令(跑在 Tauri 线程池)——`ProviderService::switch`
|
||||
/// 内部使用 block_on 获取切换锁,放进 async 命令会在运行时线程上 panic。
|
||||
#[tauri::command]
|
||||
pub fn apply_profile(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let warnings = ProfileService::apply(&state, &id).map_err(|e| e.to_string())?;
|
||||
emit_profile_apply_events(&app, &state, &id);
|
||||
Ok(warnings)
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub mod failover;
|
||||
pub mod mcp;
|
||||
pub mod profiles;
|
||||
pub mod prompts;
|
||||
pub mod providers;
|
||||
pub mod providers_seed;
|
||||
@@ -15,5 +16,6 @@ pub mod universal_providers;
|
||||
pub mod usage_rollup;
|
||||
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
// 导出 FailoverQueueItem 供外部使用
|
||||
// 导出 FailoverQueueItem / Profile 供外部使用
|
||||
pub use failover::FailoverQueueItem;
|
||||
pub use profiles::Profile;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! 项目 Profile 数据访问对象
|
||||
//!
|
||||
//! profiles 表存放按 app 快照的配置方案(供应商/MCP/Skills/Prompt),
|
||||
//! payload 为原始 JSON 文本,解析在 service 层进行。
|
||||
//! current_profile_id 存放于 settings 表(key-value)。
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use rusqlite::params;
|
||||
|
||||
const CURRENT_PROFILE_ID_KEY: &str = "current_profile_id";
|
||||
|
||||
/// 项目 Profile 记录
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// 原始 JSON 快照文本(ProfilePayload),解析在 service 层
|
||||
pub payload: String,
|
||||
pub sort_order: Option<i64>,
|
||||
pub created_at: Option<i64>,
|
||||
pub updated_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 获取所有项目(按 sort_order 优先、created_at 兜底排序)
|
||||
pub fn get_all_profiles(&self) -> Result<Vec<Profile>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, payload, sort_order, created_at, updated_at
|
||||
FROM profiles
|
||||
ORDER BY sort_order IS NULL, sort_order, created_at, id",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Profile {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
payload: row.get(2)?,
|
||||
sort_order: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut profiles = Vec::new();
|
||||
for row in rows {
|
||||
profiles.push(row.map_err(|e| AppError::Database(e.to_string()))?);
|
||||
}
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// 获取单个项目
|
||||
pub fn get_profile(&self, id: &str) -> Result<Option<Profile>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, payload, sort_order, created_at, updated_at
|
||||
FROM profiles WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
match stmt.query_row(params![id], |row| {
|
||||
Ok(Profile {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
payload: row.get(2)?,
|
||||
sort_order: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
}) {
|
||||
Ok(profile) => Ok(Some(profile)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存项目(插入或整行替换)
|
||||
pub fn save_profile(&self, profile: &Profile) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO profiles
|
||||
(id, name, payload, sort_order, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
profile.id,
|
||||
profile.name,
|
||||
profile.payload,
|
||||
profile.sort_order,
|
||||
profile.created_at,
|
||||
profile.updated_at,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除项目,返回是否实际删除了记录
|
||||
pub fn delete_profile(&self, id: &str) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute("DELETE FROM profiles WHERE id = ?1", params![id])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
}
|
||||
|
||||
/// 读取当前激活的项目 id(未使用项目时为 None)
|
||||
pub fn get_current_profile_id(&self) -> Result<Option<String>, AppError> {
|
||||
self.get_setting(CURRENT_PROFILE_ID_KEY)
|
||||
}
|
||||
|
||||
/// 设置当前激活的项目 id;None 表示"不使用项目"(删除 key)
|
||||
pub fn set_current_profile_id(&self, id: Option<&str>) -> Result<(), AppError> {
|
||||
match id {
|
||||
Some(id) => self.set_setting(CURRENT_PROFILE_ID_KEY, id),
|
||||
None => {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"DELETE FROM settings WHERE key = ?1",
|
||||
params![CURRENT_PROFILE_ID_KEY],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample(id: &str, name: &str, sort_order: Option<i64>) -> Profile {
|
||||
Profile {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
payload: r#"{"providers":{"claude":null,"codex":null}}"#.to_string(),
|
||||
sort_order,
|
||||
created_at: Some(1_000),
|
||||
updated_at: Some(1_000),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_crud_roundtrip() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
db.save_profile(&sample("a", "Dev", Some(2)))?;
|
||||
db.save_profile(&sample("b", "Draw", Some(1)))?;
|
||||
db.save_profile(&sample("c", "Misc", None))?;
|
||||
|
||||
// sort_order 优先,NULL 排最后
|
||||
let all = db.get_all_profiles()?;
|
||||
assert_eq!(
|
||||
all.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["b", "a", "c"]
|
||||
);
|
||||
|
||||
let got = db.get_profile("a")?.expect("profile a exists");
|
||||
assert_eq!(got.name, "Dev");
|
||||
assert!(got.payload.contains("providers"));
|
||||
|
||||
// 整行替换更新
|
||||
let mut updated = sample("a", "Dev Renamed", Some(2));
|
||||
updated.updated_at = Some(2_000);
|
||||
db.save_profile(&updated)?;
|
||||
let got = db.get_profile("a")?.expect("profile a exists");
|
||||
assert_eq!(got.name, "Dev Renamed");
|
||||
assert_eq!(got.updated_at, Some(2_000));
|
||||
|
||||
assert!(db.delete_profile("a")?);
|
||||
assert!(!db.delete_profile("a")?);
|
||||
assert!(db.get_profile("a")?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_current_profile_id_set_and_clear() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
assert_eq!(db.get_current_profile_id()?, None);
|
||||
db.set_current_profile_id(Some("a"))?;
|
||||
assert_eq!(db.get_current_profile_id()?, Some("a".to_string()));
|
||||
db.set_current_profile_id(None)?;
|
||||
assert_eq!(db.get_current_profile_id()?, None);
|
||||
// 重复清除应幂等
|
||||
db.set_current_profile_id(None)?;
|
||||
assert_eq!(db.get_current_profile_id()?, None);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ pub(crate) use dao::proxy::{
|
||||
PRICING_SOURCE_RESPONSE,
|
||||
};
|
||||
pub use dao::FailoverQueueItem;
|
||||
pub use dao::Profile;
|
||||
|
||||
use crate::config::get_app_config_dir;
|
||||
use crate::error::AppError;
|
||||
@@ -49,7 +50,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 11;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 12;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -295,6 +295,20 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 19. Profiles 表(项目配置方案:按 app 快照供应商/MCP/Skills/Prompt)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
sort_order INTEGER,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 尝试添加 live_takeover_active 列到 proxy_config 表
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
||||
@@ -444,6 +458,11 @@ impl Database {
|
||||
Self::migrate_v10_to_v11(conn)?;
|
||||
Self::set_user_version(conn, 11)?;
|
||||
}
|
||||
11 => {
|
||||
log::info!("迁移数据库从 v11 到 v12(添加项目 Profiles 表)");
|
||||
Self::migrate_v11_to_v12(conn)?;
|
||||
Self::set_user_version(conn, 12)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -1270,6 +1289,24 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v11 -> v12 迁移:添加项目 Profiles 表
|
||||
/// 与 create_tables_on_conn 中的建表语句保持一致(IF NOT EXISTS 保证幂等)
|
||||
fn migrate_v11_to_v12(conn: &Connection) -> Result<(), AppError> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
sort_order INTEGER,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("v11 -> v12 创建 profiles 表失败: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
|
||||
+10
-1
@@ -42,7 +42,7 @@ pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_l
|
||||
pub use commands::open_provider_terminal;
|
||||
pub use commands::*;
|
||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||
pub use database::Database;
|
||||
pub use database::{Database, Profile};
|
||||
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
||||
pub use error::AppError;
|
||||
pub use mcp::{
|
||||
@@ -51,8 +51,10 @@ pub use mcp::{
|
||||
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
|
||||
sync_single_server_to_codex, sync_single_server_to_gemini,
|
||||
};
|
||||
pub use prompt::Prompt;
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
pub use services::{
|
||||
profile::{ProfilePayload, ProfileService},
|
||||
skill::{migrate_skills_to_ssot, ImportSkillSelection},
|
||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
||||
SkillService, SpeedtestService,
|
||||
@@ -1270,6 +1272,13 @@ pub fn run() {
|
||||
commands::enable_prompt,
|
||||
commands::import_prompt_from_file,
|
||||
commands::get_current_prompt_file_content,
|
||||
// Profile management (项目配置方案)
|
||||
commands::list_profiles,
|
||||
commands::create_profile,
|
||||
commands::update_profile,
|
||||
commands::delete_profile,
|
||||
commands::clear_current_profile,
|
||||
commands::apply_profile,
|
||||
// model list fetch (OpenAI-compatible /v1/models)
|
||||
commands::fetch_models_for_config,
|
||||
// ours: endpoint speed test + custom endpoint management
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod env_manager;
|
||||
pub mod mcp;
|
||||
pub mod model_fetch;
|
||||
pub mod omo;
|
||||
pub mod profile;
|
||||
pub mod prompt;
|
||||
pub mod provider;
|
||||
pub mod proxy;
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
//! 项目 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());
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ pub struct TrayTexts {
|
||||
pub lightweight_mode: &'static str,
|
||||
pub quit: &'static str,
|
||||
pub _auto_label: &'static str,
|
||||
pub projects_label: &'static str,
|
||||
pub no_project_label: &'static str,
|
||||
}
|
||||
|
||||
impl TrayTexts {
|
||||
@@ -66,6 +68,8 @@ impl TrayTexts {
|
||||
lightweight_mode: "Lightweight Mode",
|
||||
quit: "Quit",
|
||||
_auto_label: "Auto (Failover)",
|
||||
projects_label: "Projects",
|
||||
no_project_label: "No project",
|
||||
},
|
||||
"ja" => Self {
|
||||
show_main: "メインウィンドウを開く",
|
||||
@@ -74,6 +78,8 @@ impl TrayTexts {
|
||||
lightweight_mode: "軽量モード",
|
||||
quit: "終了",
|
||||
_auto_label: "自動 (フェイルオーバー)",
|
||||
projects_label: "プロジェクト",
|
||||
no_project_label: "プロジェクトを使用しない",
|
||||
},
|
||||
"zh-TW" => Self {
|
||||
show_main: "開啟主介面",
|
||||
@@ -82,6 +88,8 @@ impl TrayTexts {
|
||||
lightweight_mode: "輕量模式",
|
||||
quit: "退出",
|
||||
_auto_label: "自動 (故障轉移)",
|
||||
projects_label: "專案",
|
||||
no_project_label: "不使用專案",
|
||||
},
|
||||
_ => Self {
|
||||
show_main: "打开主界面",
|
||||
@@ -90,6 +98,8 @@ impl TrayTexts {
|
||||
lightweight_mode: "轻量模式",
|
||||
quit: "退出",
|
||||
_auto_label: "自动 (故障转移)",
|
||||
projects_label: "项目",
|
||||
no_project_label: "不使用项目",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -326,6 +336,55 @@ fn sort_providers(
|
||||
sorted
|
||||
}
|
||||
|
||||
/// 处理项目 Profile 托盘事件,返回是否已处理
|
||||
///
|
||||
/// 事件 id 形如 `profile_<uuid>`;`profile_none` 表示"不使用项目"(只清标记,不动配置)。
|
||||
pub fn handle_profile_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
||||
let Some(suffix) = event_id.strip_prefix("profile_") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if suffix == "none" {
|
||||
if let Some(app_state) = app.try_state::<AppState>() {
|
||||
if let Err(e) = app_state.db.set_current_profile_id(None) {
|
||||
log::error!("清除当前项目失败: {e}");
|
||||
}
|
||||
}
|
||||
// 通知主窗口刷新(profileId=null 表示已清除当前项目)
|
||||
if let Err(e) = app.emit("profile-applied", serde_json::json!({ "profileId": null })) {
|
||||
log::error!("发射 profile-applied 事件失败: {e}");
|
||||
}
|
||||
refresh_tray_menu(app);
|
||||
return true;
|
||||
}
|
||||
|
||||
log::info!("应用项目: {suffix}");
|
||||
let app_handle = app.clone();
|
||||
let profile_id = suffix.to_string();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let Some(app_state) = app_handle.try_state::<AppState>() else {
|
||||
return;
|
||||
};
|
||||
match crate::services::profile::ProfileService::apply(app_state.inner(), &profile_id) {
|
||||
Ok(warnings) => {
|
||||
for warning in &warnings {
|
||||
log::warn!("[Profile] 应用项目 {profile_id} 警告: {warning}");
|
||||
}
|
||||
crate::commands::emit_profile_apply_events(
|
||||
&app_handle,
|
||||
app_state.inner(),
|
||||
&profile_id,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("应用项目 {profile_id} 失败: {e}");
|
||||
refresh_tray_menu(&app_handle);
|
||||
}
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// 处理供应商托盘事件
|
||||
pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
@@ -605,6 +664,43 @@ pub fn create_tray_menu(
|
||||
menu_builder = menu_builder.separator();
|
||||
}
|
||||
|
||||
// 项目 Profile 子菜单(Claude/Codex 至少一个可见且存在项目时才显示)
|
||||
if visible_apps.is_visible(&AppType::Claude) || visible_apps.is_visible(&AppType::Codex) {
|
||||
let profiles = app_state.db.get_all_profiles()?;
|
||||
if !profiles.is_empty() {
|
||||
let current_profile_id = app_state.db.get_current_profile_id()?.unwrap_or_default();
|
||||
let mut profiles_builder =
|
||||
SubmenuBuilder::with_id(app, "submenu_profiles", tray_texts.projects_label);
|
||||
for profile in &profiles {
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("profile_{}", profile.id),
|
||||
&profile.name,
|
||||
true,
|
||||
current_profile_id == profile.id,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建项目菜单项失败: {e}")))?;
|
||||
profiles_builder = profiles_builder.item(&item);
|
||||
}
|
||||
let none_item = CheckMenuItem::with_id(
|
||||
app,
|
||||
"profile_none",
|
||||
tray_texts.no_project_label,
|
||||
true,
|
||||
current_profile_id.is_empty(),
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建不使用项目菜单项失败: {e}")))?;
|
||||
let profiles_submenu = profiles_builder
|
||||
.separator()
|
||||
.item(&none_item)
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("构建项目子菜单失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&profiles_submenu).separator();
|
||||
}
|
||||
}
|
||||
|
||||
let lightweight_item = CheckMenuItem::with_id(
|
||||
app,
|
||||
"lightweight_mode",
|
||||
@@ -750,6 +846,9 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {
|
||||
if handle_profile_tray_event(app, event_id) {
|
||||
return;
|
||||
}
|
||||
if handle_provider_tray_event(app, event_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
//! 项目 Profile 快照/应用的端到端集成测试
|
||||
//!
|
||||
//! 全链路 apply 会写 live 配置文件——support.rs 已把 HOME 指向临时目录,安全。
|
||||
|
||||
use std::fs;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use cc_switch_lib::{
|
||||
AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileService, Prompt,
|
||||
PromptService, Provider, ProviderService, SkillApps, SkillService,
|
||||
};
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
|
||||
|
||||
fn claude_provider(id: &str, token: &str) -> Provider {
|
||||
Provider::with_id(
|
||||
id.to_string(),
|
||||
id.to_uppercase(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": token,
|
||||
"ANTHROPIC_BASE_URL": "https://api.test"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn mcp_server(id: &str, claude_enabled: bool) -> McpServer {
|
||||
serde_json::from_value(json!({
|
||||
"id": id,
|
||||
"name": id,
|
||||
"server": { "command": "echo", "args": [] },
|
||||
"apps": { "claude": claude_enabled }
|
||||
}))
|
||||
.expect("construct mcp server")
|
||||
}
|
||||
|
||||
fn prompt(id: &str, enabled: bool) -> Prompt {
|
||||
Prompt {
|
||||
id: id.to_string(),
|
||||
name: id.to_uppercase(),
|
||||
content: format!("# prompt {id}\n"),
|
||||
description: None,
|
||||
enabled,
|
||||
created_at: Some(1_000),
|
||||
updated_at: Some(1_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_skill(id: &str, directory: &str, claude_enabled: bool) -> InstalledSkill {
|
||||
InstalledSkill {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
description: None,
|
||||
directory: directory.to_string(),
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
readme_url: None,
|
||||
apps: SkillApps {
|
||||
claude: claude_enabled,
|
||||
..Default::default()
|
||||
},
|
||||
installed_at: 1_000,
|
||||
content_hash: None,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ssot_skill(directory: &str) {
|
||||
let dir = SkillService::get_ssot_dir()
|
||||
.expect("resolve skills SSOT dir")
|
||||
.join(directory);
|
||||
fs::create_dir_all(&dir).expect("create skill dir");
|
||||
fs::write(
|
||||
dir.join("SKILL.md"),
|
||||
format!("---\nname: {directory}\ndescription: Test skill\n---\n"),
|
||||
)
|
||||
.expect("write SKILL.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_snapshot_apply_roundtrip_restores_configuration() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
// ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ----
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
|
||||
.expect("save provider p1");
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
|
||||
.expect("save provider p2");
|
||||
state
|
||||
.db
|
||||
.set_current_provider(AppType::Claude.as_str(), "p1")
|
||||
.expect("set current provider p1");
|
||||
|
||||
// 让 live settings.json 与 p1 一致(switch_normal 回填需要)
|
||||
let claude_dir = home.join(".claude");
|
||||
fs::create_dir_all(&claude_dir).expect("create .claude dir");
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config)
|
||||
.expect("serialize p1 settings"),
|
||||
)
|
||||
.expect("seed live settings.json");
|
||||
|
||||
state
|
||||
.db
|
||||
.save_mcp_server(&mcp_server("m1", true))
|
||||
.expect("save mcp m1");
|
||||
state
|
||||
.db
|
||||
.save_mcp_server(&mcp_server("m2", false))
|
||||
.expect("save mcp m2");
|
||||
|
||||
write_ssot_skill("test-skill");
|
||||
state
|
||||
.db
|
||||
.save_skill(&installed_skill("local:test-skill", "test-skill", true))
|
||||
.expect("save skill");
|
||||
|
||||
state
|
||||
.db
|
||||
.save_prompt(AppType::Claude.as_str(), &prompt("pr1", true))
|
||||
.expect("save prompt pr1");
|
||||
state
|
||||
.db
|
||||
.save_prompt(AppType::Claude.as_str(), &prompt("pr2", false))
|
||||
.expect("save prompt pr2");
|
||||
|
||||
// ---- 保存项目 A(快照当前状态)----
|
||||
let profile_a = ProfileService::create(&state, "Project A").expect("create profile A");
|
||||
let payload: ProfilePayload =
|
||||
serde_json::from_str(&profile_a.payload).expect("parse profile A payload");
|
||||
assert_eq!(payload.providers.claude.as_deref(), Some("p1"));
|
||||
assert_eq!(payload.mcp.claude, vec!["m1".to_string()]);
|
||||
assert_eq!(payload.skills.claude, vec!["local:test-skill".to_string()]);
|
||||
assert_eq!(payload.prompts.claude.as_deref(), Some("pr1"));
|
||||
assert_eq!(
|
||||
payload.providers.codex, None,
|
||||
"codex has no current provider"
|
||||
);
|
||||
assert!(payload.mcp.codex.is_empty());
|
||||
|
||||
// ---- 改动全部四类配置(走真实切换路径)----
|
||||
ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2");
|
||||
McpService::toggle_app(&state, "m1", AppType::Claude, false).expect("disable m1");
|
||||
McpService::toggle_app(&state, "m2", AppType::Claude, true).expect("enable m2");
|
||||
SkillService::toggle_app(&state.db, "local:test-skill", &AppType::Claude, false)
|
||||
.expect("disable skill");
|
||||
PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2");
|
||||
|
||||
// ---- 应用项目 A:全部复原 ----
|
||||
let warnings = ProfileService::apply(&state, &profile_a.id).expect("apply profile A");
|
||||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||||
|
||||
let current = state
|
||||
.db
|
||||
.get_current_provider(AppType::Claude.as_str())
|
||||
.expect("get current provider");
|
||||
assert_eq!(current.as_deref(), Some("p1"), "provider restored to p1");
|
||||
|
||||
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
|
||||
assert!(servers.get("m1").expect("m1").apps.claude, "m1 re-enabled");
|
||||
assert!(!servers.get("m2").expect("m2").apps.claude, "m2 disabled");
|
||||
|
||||
let skills = state.db.get_all_installed_skills().expect("get skills");
|
||||
assert!(
|
||||
skills.get("local:test-skill").expect("skill").apps.claude,
|
||||
"skill re-enabled"
|
||||
);
|
||||
|
||||
let prompts = state
|
||||
.db
|
||||
.get_prompts(AppType::Claude.as_str())
|
||||
.expect("get prompts");
|
||||
assert!(prompts.get("pr1").expect("pr1").enabled, "pr1 re-enabled");
|
||||
assert!(!prompts.get("pr2").expect("pr2").enabled, "pr2 disabled");
|
||||
|
||||
let live_prompt = fs::read_to_string(claude_dir.join("CLAUDE.md")).expect("read CLAUDE.md");
|
||||
assert_eq!(
|
||||
live_prompt,
|
||||
prompt("pr1", true).content,
|
||||
"live memory file restored"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.db
|
||||
.get_current_profile_id()
|
||||
.expect("get current profile id")
|
||||
.as_deref(),
|
||||
Some(profile_a.id.as_str()),
|
||||
"profile A marked as current"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_apply_reports_dangling_references_and_continues() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
state
|
||||
.db
|
||||
.save_mcp_server(&mcp_server("m1", false))
|
||||
.expect("save mcp m1");
|
||||
|
||||
// 手工构造引用了不存在资源的 payload
|
||||
let payload = json!({
|
||||
"providers": { "claude": "ghost-provider" },
|
||||
"mcp": { "claude": ["m1", "ghost-mcp"] },
|
||||
"skills": { "claude": ["ghost-skill"] },
|
||||
"prompts": { "claude": "ghost-prompt" }
|
||||
});
|
||||
let profile = cc_switch_lib::Profile {
|
||||
id: "dangling-test".to_string(),
|
||||
name: "Dangling".to_string(),
|
||||
payload: payload.to_string(),
|
||||
sort_order: None,
|
||||
created_at: Some(1_000),
|
||||
updated_at: Some(1_000),
|
||||
};
|
||||
state.db.save_profile(&profile).expect("save profile");
|
||||
|
||||
let warnings = ProfileService::apply(&state, "dangling-test").expect("apply succeeds");
|
||||
assert_eq!(
|
||||
warnings.len(),
|
||||
4,
|
||||
"each dangling reference yields one warning: {warnings:?}"
|
||||
);
|
||||
|
||||
// 有效条目照常生效:m1 被启用
|
||||
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
|
||||
assert!(
|
||||
servers.get("m1").expect("m1").apps.claude,
|
||||
"m1 enabled despite warnings"
|
||||
);
|
||||
|
||||
// best-effort 完成后仍标记为当前项目
|
||||
assert_eq!(
|
||||
state
|
||||
.db
|
||||
.get_current_profile_id()
|
||||
.expect("get current profile id")
|
||||
.as_deref(),
|
||||
Some("dangling-test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_current_profile_only_clears_marker() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
state
|
||||
.db
|
||||
.set_current_profile_id(Some("some-profile"))
|
||||
.expect("set current profile");
|
||||
state
|
||||
.db
|
||||
.set_current_profile_id(None)
|
||||
.expect("clear current profile");
|
||||
assert_eq!(
|
||||
state
|
||||
.db
|
||||
.get_current_profile_id()
|
||||
.expect("get current profile id"),
|
||||
None
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user