2025-08-30 21:54:11 +08:00
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
use std::collections::HashMap;
|
2025-10-30 12:33:35 +08:00
|
|
|
|
use std::str::FromStr;
|
2025-08-30 21:54:11 +08:00
|
|
|
|
|
2025-11-14 12:51:24 +08:00
|
|
|
|
/// MCP 服务器应用状态(标记应用到哪些客户端)
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
|
|
|
|
|
pub struct McpApps {
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub claude: bool,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub codex: bool,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub gemini: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl McpApps {
|
|
|
|
|
|
/// 检查指定应用是否启用
|
|
|
|
|
|
pub fn is_enabled_for(&self, app: &AppType) -> bool {
|
|
|
|
|
|
match app {
|
|
|
|
|
|
AppType::Claude => self.claude,
|
|
|
|
|
|
AppType::Codex => self.codex,
|
|
|
|
|
|
AppType::Gemini => self.gemini,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 设置指定应用的启用状态
|
|
|
|
|
|
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
|
|
|
|
|
|
match app {
|
|
|
|
|
|
AppType::Claude => self.claude = enabled,
|
|
|
|
|
|
AppType::Codex => self.codex = enabled,
|
|
|
|
|
|
AppType::Gemini => self.gemini = enabled,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 获取所有启用的应用列表
|
|
|
|
|
|
pub fn enabled_apps(&self) -> Vec<AppType> {
|
|
|
|
|
|
let mut apps = Vec::new();
|
|
|
|
|
|
if self.claude {
|
|
|
|
|
|
apps.push(AppType::Claude);
|
|
|
|
|
|
}
|
|
|
|
|
|
if self.codex {
|
|
|
|
|
|
apps.push(AppType::Codex);
|
|
|
|
|
|
}
|
|
|
|
|
|
if self.gemini {
|
|
|
|
|
|
apps.push(AppType::Gemini);
|
|
|
|
|
|
}
|
|
|
|
|
|
apps
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 检查是否所有应用都未启用
|
|
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
|
|
|
|
!self.claude && !self.codex && !self.gemini
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// MCP 服务器定义(v3.7.0 统一结构)
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct McpServer {
|
|
|
|
|
|
pub id: String,
|
|
|
|
|
|
pub name: String,
|
|
|
|
|
|
pub server: serde_json::Value,
|
|
|
|
|
|
pub apps: McpApps,
|
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub description: Option<String>,
|
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub homepage: Option<String>,
|
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub docs: Option<String>,
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
|
pub tags: Vec<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// MCP 配置:单客户端维度(v3.6.x 及以前,保留用于向后兼容)
|
2025-10-09 21:08:42 +08:00
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
|
|
|
|
pub struct McpConfig {
|
|
|
|
|
|
/// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub servers: HashMap<String, serde_json::Value>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-14 12:51:24 +08:00
|
|
|
|
impl McpConfig {
|
|
|
|
|
|
/// 检查配置是否为空
|
|
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
|
|
|
|
self.servers.is_empty()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// MCP 根配置(v3.7.0 新旧结构并存)
|
2025-11-14 22:55:46 +08:00
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
2025-10-09 22:02:56 +08:00
|
|
|
|
pub struct McpRoot {
|
2025-11-14 12:51:24 +08:00
|
|
|
|
/// 统一的 MCP 服务器存储(v3.7.0+)
|
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub servers: Option<HashMap<String, McpServer>>,
|
|
|
|
|
|
|
|
|
|
|
|
/// 旧的分应用存储(v3.6.x 及以前,保留用于迁移)
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
2025-10-09 22:02:56 +08:00
|
|
|
|
pub claude: McpConfig,
|
2025-11-14 12:51:24 +08:00
|
|
|
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
2025-10-09 22:02:56 +08:00
|
|
|
|
pub codex: McpConfig,
|
2025-11-14 12:51:24 +08:00
|
|
|
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
|
|
|
|
|
pub gemini: McpConfig,
|
2025-10-09 22:02:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-14 22:55:46 +08:00
|
|
|
|
impl Default for McpRoot {
|
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
// v3.7.0+ 默认使用新的统一结构(空 HashMap)
|
|
|
|
|
|
servers: Some(HashMap::new()),
|
|
|
|
|
|
// 旧结构保持空,仅用于反序列化旧配置时的迁移
|
|
|
|
|
|
claude: McpConfig::default(),
|
|
|
|
|
|
codex: McpConfig::default(),
|
|
|
|
|
|
gemini: McpConfig::default(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(prompts+i18n): add prompt management and improve prompt editor i18n (#193)
* feat(prompts): add prompt management across Tauri service and React UI
- backend: add commands/prompt.rs, services/prompt.rs, register in commands/mod.rs and lib.rs, refine app_config.rs
- frontend: add PromptPanel, PromptFormModal, PromptListItem, MarkdownEditor, usePromptActions, integrate in App.tsx
- api: add src/lib/api/prompts.ts
- i18n: update src/i18n/locales/{en,zh}.json
- build: update package.json and pnpm-lock.yaml
* feat(i18n): improve i18n for prompts and Markdown editor
- update src/i18n/locales/{en,zh}.json keys and strings
- apply i18n in PromptFormModal, PromptPanel, and MarkdownEditor
- align prompt text with src-tauri/src/services/prompt.rs
* feat(prompts): add enable/disable toggle and simplify panel UI
- Add PromptToggle component and integrate in prompt list items
- Implement toggleEnabled with optimistic update; enable via API, disable via upsert with enabled=false;
reload after success
- Simplify PromptPanel: remove file import and current-file preview to keep CRUD flow focused
- Tweak header controls style (use mcp variant) and minor copy: rename “Prompt Management” to “Prompts”
- i18n: add disableSuccess/disableFailed messages
- Backend (Tauri): prevent duplicate backups when importing original prompt content
* style: unify code formatting with trailing commas
* feat(prompts): add Gemini filename support to PromptFormModal
Update filename mapping to use Record<AppId, string> pattern, supporting
GEMINI.md alongside CLAUDE.md and AGENTS.md.
* fix(prompts): sync enabled prompt to file when updating
When updating a prompt that is currently enabled, automatically sync
the updated content to the corresponding live file (CLAUDE.md/AGENTS.md/GEMINI.md).
This ensures the active prompt file always reflects the latest content
when editing enabled prompts.
2025-11-12 16:41:41 +08:00
|
|
|
|
/// Prompt 配置:单客户端维度
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
|
|
|
|
pub struct PromptConfig {
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub prompts: HashMap<String, crate::prompt::Prompt>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Prompt 根:按客户端分开维护
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
|
|
|
|
pub struct PromptRoot {
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub claude: PromptConfig,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub codex: PromptConfig,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub gemini: PromptConfig,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-01 10:49:31 +08:00
|
|
|
|
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
refactor(backend): phase 1 - unified error handling with thiserror
Introduce AppError enum to replace Result<T, String> pattern across
the codebase, improving error context preservation and type safety.
## Changes
### Core Infrastructure
- Add src/error.rs with AppError enum using thiserror
- Add thiserror dependency to Cargo.toml
- Implement helper functions: io(), json(), toml() for ergonomic error creation
- Implement From<PoisonError> for automatic lock error conversion
- Implement From<AppError> for String to maintain Tauri command compatibility
### Module Migrations (60% complete)
- config.rs: Full migration to AppError
- read_json_file, write_json_file, atomic_write
- archive_file, copy_file, delete_file
- claude_mcp.rs: Full migration to AppError
- get_mcp_status, read_mcp_json, upsert_mcp_server
- delete_mcp_server, validate_command_in_path
- set_mcp_servers_map
- codex_config.rs: Full migration to AppError
- write_codex_live_atomic with rollback support
- read_and_validate_codex_config_text
- validate_config_toml
- app_config.rs: Partial migration
- MultiAppConfig::load, MultiAppConfig::save
- store.rs: Partial migration
- AppState::save now returns Result<(), AppError>
- commands.rs: Minimal changes
- Use .map_err(Into::into) for compatibility
- mcp.rs: Minimal changes
- sync_enabled_to_claude uses Into::into conversion
### Documentation
- Add docs/BACKEND_REFACTOR_PLAN.md with detailed refactoring roadmap
## Benefits
- Type-safe error handling with preserved error chains
- Better error messages with file paths and context
- Reduced boilerplate code (118 Result<T, String> instances to migrate)
- Automatic error conversion for seamless integration
## Testing
- All existing tests pass (4/4)
- Compilation successful with no warnings
- Build time: 0.61s (no performance regression)
## Remaining Work
- claude_plugin.rs (7 functions)
- migration.rs, import_export.rs
- Add unit tests for error.rs
- Complete commands.rs migration after dependent modules
Co-authored-by: Claude <claude@anthropic.com>
2025-10-27 16:29:11 +08:00
|
|
|
|
use crate::error::AppError;
|
2025-11-13 15:15:58 +08:00
|
|
|
|
use crate::prompt_files::prompt_file_path;
|
2025-08-30 21:54:11 +08:00
|
|
|
|
use crate::provider::ProviderManager;
|
|
|
|
|
|
|
|
|
|
|
|
/// 应用类型
|
2025-11-12 10:47:34 +08:00
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
2025-08-30 21:54:11 +08:00
|
|
|
|
#[serde(rename_all = "lowercase")]
|
|
|
|
|
|
pub enum AppType {
|
|
|
|
|
|
Claude,
|
|
|
|
|
|
Codex,
|
2025-11-12 23:38:43 +08:00
|
|
|
|
Gemini, // 新增
|
2025-08-30 21:54:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl AppType {
|
|
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
AppType::Claude => "claude",
|
|
|
|
|
|
AppType::Codex => "codex",
|
2025-11-12 23:38:43 +08:00
|
|
|
|
AppType::Gemini => "gemini", // 新增
|
2025-08-30 21:54:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-30 12:33:35 +08:00
|
|
|
|
impl FromStr for AppType {
|
|
|
|
|
|
type Err = AppError;
|
|
|
|
|
|
|
|
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
|
|
|
|
let normalized = s.trim().to_lowercase();
|
|
|
|
|
|
match normalized.as_str() {
|
|
|
|
|
|
"claude" => Ok(AppType::Claude),
|
|
|
|
|
|
"codex" => Ok(AppType::Codex),
|
2025-11-12 23:38:43 +08:00
|
|
|
|
"gemini" => Ok(AppType::Gemini), // 新增
|
2025-10-30 12:33:35 +08:00
|
|
|
|
other => Err(AppError::localized(
|
|
|
|
|
|
"unsupported_app",
|
2025-11-12 10:47:34 +08:00
|
|
|
|
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
|
|
|
|
|
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
|
2025-10-30 12:33:35 +08:00
|
|
|
|
)),
|
2025-08-30 21:54:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 多应用配置结构(向后兼容)
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
|
pub struct MultiAppConfig {
|
|
|
|
|
|
#[serde(default = "default_version")]
|
|
|
|
|
|
pub version: u32,
|
2025-10-09 21:08:42 +08:00
|
|
|
|
/// 应用管理器(claude/codex)
|
2025-08-30 21:54:11 +08:00
|
|
|
|
#[serde(flatten)]
|
|
|
|
|
|
pub apps: HashMap<String, ProviderManager>,
|
2025-10-09 22:02:56 +08:00
|
|
|
|
/// MCP 配置(按客户端分治)
|
2025-10-09 21:08:42 +08:00
|
|
|
|
#[serde(default)]
|
2025-10-09 22:02:56 +08:00
|
|
|
|
pub mcp: McpRoot,
|
feat(prompts+i18n): add prompt management and improve prompt editor i18n (#193)
* feat(prompts): add prompt management across Tauri service and React UI
- backend: add commands/prompt.rs, services/prompt.rs, register in commands/mod.rs and lib.rs, refine app_config.rs
- frontend: add PromptPanel, PromptFormModal, PromptListItem, MarkdownEditor, usePromptActions, integrate in App.tsx
- api: add src/lib/api/prompts.ts
- i18n: update src/i18n/locales/{en,zh}.json
- build: update package.json and pnpm-lock.yaml
* feat(i18n): improve i18n for prompts and Markdown editor
- update src/i18n/locales/{en,zh}.json keys and strings
- apply i18n in PromptFormModal, PromptPanel, and MarkdownEditor
- align prompt text with src-tauri/src/services/prompt.rs
* feat(prompts): add enable/disable toggle and simplify panel UI
- Add PromptToggle component and integrate in prompt list items
- Implement toggleEnabled with optimistic update; enable via API, disable via upsert with enabled=false;
reload after success
- Simplify PromptPanel: remove file import and current-file preview to keep CRUD flow focused
- Tweak header controls style (use mcp variant) and minor copy: rename “Prompt Management” to “Prompts”
- i18n: add disableSuccess/disableFailed messages
- Backend (Tauri): prevent duplicate backups when importing original prompt content
* style: unify code formatting with trailing commas
* feat(prompts): add Gemini filename support to PromptFormModal
Update filename mapping to use Record<AppId, string> pattern, supporting
GEMINI.md alongside CLAUDE.md and AGENTS.md.
* fix(prompts): sync enabled prompt to file when updating
When updating a prompt that is currently enabled, automatically sync
the updated content to the corresponding live file (CLAUDE.md/AGENTS.md/GEMINI.md).
This ensures the active prompt file always reflects the latest content
when editing enabled prompts.
2025-11-12 16:41:41 +08:00
|
|
|
|
/// Prompt 配置(按客户端分治)
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub prompts: PromptRoot,
|
2025-11-13 22:45:58 +08:00
|
|
|
|
/// Claude 通用配置片段(JSON 字符串,用于跨供应商共享配置)
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub claude_common_config_snippet: Option<String>,
|
2025-08-30 21:54:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn default_version() -> u32 {
|
|
|
|
|
|
2
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Default for MultiAppConfig {
|
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
|
let mut apps = HashMap::new();
|
|
|
|
|
|
apps.insert("claude".to_string(), ProviderManager::default());
|
|
|
|
|
|
apps.insert("codex".to_string(), ProviderManager::default());
|
2025-11-12 23:38:43 +08:00
|
|
|
|
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
2025-08-30 21:54:11 +08:00
|
|
|
|
|
2025-10-09 21:08:42 +08:00
|
|
|
|
Self {
|
|
|
|
|
|
version: 2,
|
|
|
|
|
|
apps,
|
2025-10-09 22:02:56 +08:00
|
|
|
|
mcp: McpRoot::default(),
|
feat(prompts+i18n): add prompt management and improve prompt editor i18n (#193)
* feat(prompts): add prompt management across Tauri service and React UI
- backend: add commands/prompt.rs, services/prompt.rs, register in commands/mod.rs and lib.rs, refine app_config.rs
- frontend: add PromptPanel, PromptFormModal, PromptListItem, MarkdownEditor, usePromptActions, integrate in App.tsx
- api: add src/lib/api/prompts.ts
- i18n: update src/i18n/locales/{en,zh}.json
- build: update package.json and pnpm-lock.yaml
* feat(i18n): improve i18n for prompts and Markdown editor
- update src/i18n/locales/{en,zh}.json keys and strings
- apply i18n in PromptFormModal, PromptPanel, and MarkdownEditor
- align prompt text with src-tauri/src/services/prompt.rs
* feat(prompts): add enable/disable toggle and simplify panel UI
- Add PromptToggle component and integrate in prompt list items
- Implement toggleEnabled with optimistic update; enable via API, disable via upsert with enabled=false;
reload after success
- Simplify PromptPanel: remove file import and current-file preview to keep CRUD flow focused
- Tweak header controls style (use mcp variant) and minor copy: rename “Prompt Management” to “Prompts”
- i18n: add disableSuccess/disableFailed messages
- Backend (Tauri): prevent duplicate backups when importing original prompt content
* style: unify code formatting with trailing commas
* feat(prompts): add Gemini filename support to PromptFormModal
Update filename mapping to use Record<AppId, string> pattern, supporting
GEMINI.md alongside CLAUDE.md and AGENTS.md.
* fix(prompts): sync enabled prompt to file when updating
When updating a prompt that is currently enabled, automatically sync
the updated content to the corresponding live file (CLAUDE.md/AGENTS.md/GEMINI.md).
This ensures the active prompt file always reflects the latest content
when editing enabled prompts.
2025-11-12 16:41:41 +08:00
|
|
|
|
prompts: PromptRoot::default(),
|
2025-11-13 22:45:58 +08:00
|
|
|
|
claude_common_config_snippet: None,
|
2025-10-09 21:08:42 +08:00
|
|
|
|
}
|
2025-08-30 21:54:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl MultiAppConfig {
|
2025-11-06 09:18:21 +08:00
|
|
|
|
/// 从文件加载配置(仅支持 v2 结构)
|
refactor(backend): phase 1 - unified error handling with thiserror
Introduce AppError enum to replace Result<T, String> pattern across
the codebase, improving error context preservation and type safety.
## Changes
### Core Infrastructure
- Add src/error.rs with AppError enum using thiserror
- Add thiserror dependency to Cargo.toml
- Implement helper functions: io(), json(), toml() for ergonomic error creation
- Implement From<PoisonError> for automatic lock error conversion
- Implement From<AppError> for String to maintain Tauri command compatibility
### Module Migrations (60% complete)
- config.rs: Full migration to AppError
- read_json_file, write_json_file, atomic_write
- archive_file, copy_file, delete_file
- claude_mcp.rs: Full migration to AppError
- get_mcp_status, read_mcp_json, upsert_mcp_server
- delete_mcp_server, validate_command_in_path
- set_mcp_servers_map
- codex_config.rs: Full migration to AppError
- write_codex_live_atomic with rollback support
- read_and_validate_codex_config_text
- validate_config_toml
- app_config.rs: Partial migration
- MultiAppConfig::load, MultiAppConfig::save
- store.rs: Partial migration
- AppState::save now returns Result<(), AppError>
- commands.rs: Minimal changes
- Use .map_err(Into::into) for compatibility
- mcp.rs: Minimal changes
- sync_enabled_to_claude uses Into::into conversion
### Documentation
- Add docs/BACKEND_REFACTOR_PLAN.md with detailed refactoring roadmap
## Benefits
- Type-safe error handling with preserved error chains
- Better error messages with file paths and context
- Reduced boilerplate code (118 Result<T, String> instances to migrate)
- Automatic error conversion for seamless integration
## Testing
- All existing tests pass (4/4)
- Compilation successful with no warnings
- Build time: 0.61s (no performance regression)
## Remaining Work
- claude_plugin.rs (7 functions)
- migration.rs, import_export.rs
- Add unit tests for error.rs
- Complete commands.rs migration after dependent modules
Co-authored-by: Claude <claude@anthropic.com>
2025-10-27 16:29:11 +08:00
|
|
|
|
pub fn load() -> Result<Self, AppError> {
|
2025-08-30 21:54:11 +08:00
|
|
|
|
let config_path = get_app_config_path();
|
|
|
|
|
|
|
|
|
|
|
|
if !config_path.exists() {
|
2025-11-13 15:15:58 +08:00
|
|
|
|
log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词");
|
|
|
|
|
|
// 使用新的方法,支持自动导入提示词
|
|
|
|
|
|
let config = Self::default_with_auto_import()?;
|
|
|
|
|
|
// 立即保存到磁盘
|
|
|
|
|
|
config.save()?;
|
|
|
|
|
|
return Ok(config);
|
2025-08-30 21:54:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 尝试读取文件
|
2025-10-28 11:58:57 +08:00
|
|
|
|
let content =
|
|
|
|
|
|
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
2025-08-30 21:54:11 +08:00
|
|
|
|
|
2025-11-06 09:18:21 +08:00
|
|
|
|
// 先解析为 Value,以便严格判定是否为 v1 结构;
|
|
|
|
|
|
// 满足:顶层同时包含 providers(object) + current(string),且不包含 version/apps/mcp 关键键,即视为 v1
|
|
|
|
|
|
let value: serde_json::Value =
|
|
|
|
|
|
serde_json::from_str(&content).map_err(|e| AppError::json(&config_path, e))?;
|
2025-11-06 16:22:05 +08:00
|
|
|
|
let is_v1 = value.as_object().is_some_and(|map| {
|
2025-11-06 16:32:45 +08:00
|
|
|
|
let has_providers = map.get("providers").map(|v| v.is_object()).unwrap_or(false);
|
|
|
|
|
|
let has_current = map.get("current").map(|v| v.is_string()).unwrap_or(false);
|
2025-11-06 09:18:21 +08:00
|
|
|
|
// v1 的充分必要条件:有 providers 和 current,且 apps 不存在(version/mcp 可能存在但不作为 v2 判据)
|
|
|
|
|
|
let has_apps = map.contains_key("apps");
|
|
|
|
|
|
has_providers && has_current && !has_apps
|
|
|
|
|
|
});
|
|
|
|
|
|
if is_v1 {
|
|
|
|
|
|
return Err(AppError::localized(
|
|
|
|
|
|
"config.unsupported_v1",
|
|
|
|
|
|
"检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch/config.json,将顶层结构调整为:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
|
|
|
|
|
|
"Detected legacy v1 config. Runtime auto-migration is no longer supported.\n\nSolutions:\n1. Install v3.2.x for one-time auto-migration\n2. Or manually edit ~/.cc-switch/config.json to adjust the top-level structure:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
|
|
|
|
|
|
));
|
2025-08-30 21:54:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-06 09:18:21 +08:00
|
|
|
|
// 解析 v2 结构
|
2025-11-12 23:38:43 +08:00
|
|
|
|
let mut config: Self =
|
|
|
|
|
|
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
2025-11-15 16:47:08 +08:00
|
|
|
|
let mut updated = false;
|
2025-11-12 23:38:43 +08:00
|
|
|
|
|
2025-11-12 10:47:34 +08:00
|
|
|
|
// 确保 gemini 应用存在(兼容旧配置文件)
|
|
|
|
|
|
if !config.apps.contains_key("gemini") {
|
2025-11-12 23:38:43 +08:00
|
|
|
|
config
|
|
|
|
|
|
.apps
|
|
|
|
|
|
.insert("gemini".to_string(), ProviderManager::default());
|
2025-11-15 16:47:08 +08:00
|
|
|
|
updated = true;
|
2025-11-12 10:47:34 +08:00
|
|
|
|
}
|
2025-11-12 23:38:43 +08:00
|
|
|
|
|
2025-11-14 12:51:24 +08:00
|
|
|
|
// 执行 MCP 迁移(v3.6.x → v3.7.0)
|
|
|
|
|
|
let migrated = config.migrate_mcp_to_unified()?;
|
|
|
|
|
|
if migrated {
|
|
|
|
|
|
log::info!("MCP 配置已迁移到 v3.7.0 统一结构,保存配置...");
|
2025-11-15 16:47:08 +08:00
|
|
|
|
updated = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 对于已经存在的配置文件,如果此前版本还没有 Prompt 功能,
|
|
|
|
|
|
// 且 prompts 仍然是空的,则尝试自动导入现有提示词文件。
|
|
|
|
|
|
let imported_prompts = config.maybe_auto_import_prompts_for_existing_config()?;
|
|
|
|
|
|
if imported_prompts {
|
|
|
|
|
|
updated = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if updated {
|
|
|
|
|
|
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
2025-11-14 12:51:24 +08:00
|
|
|
|
config.save()?;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-12 10:47:34 +08:00
|
|
|
|
Ok(config)
|
2025-08-30 21:54:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 保存配置到文件
|
refactor(backend): phase 1 - unified error handling with thiserror
Introduce AppError enum to replace Result<T, String> pattern across
the codebase, improving error context preservation and type safety.
## Changes
### Core Infrastructure
- Add src/error.rs with AppError enum using thiserror
- Add thiserror dependency to Cargo.toml
- Implement helper functions: io(), json(), toml() for ergonomic error creation
- Implement From<PoisonError> for automatic lock error conversion
- Implement From<AppError> for String to maintain Tauri command compatibility
### Module Migrations (60% complete)
- config.rs: Full migration to AppError
- read_json_file, write_json_file, atomic_write
- archive_file, copy_file, delete_file
- claude_mcp.rs: Full migration to AppError
- get_mcp_status, read_mcp_json, upsert_mcp_server
- delete_mcp_server, validate_command_in_path
- set_mcp_servers_map
- codex_config.rs: Full migration to AppError
- write_codex_live_atomic with rollback support
- read_and_validate_codex_config_text
- validate_config_toml
- app_config.rs: Partial migration
- MultiAppConfig::load, MultiAppConfig::save
- store.rs: Partial migration
- AppState::save now returns Result<(), AppError>
- commands.rs: Minimal changes
- Use .map_err(Into::into) for compatibility
- mcp.rs: Minimal changes
- sync_enabled_to_claude uses Into::into conversion
### Documentation
- Add docs/BACKEND_REFACTOR_PLAN.md with detailed refactoring roadmap
## Benefits
- Type-safe error handling with preserved error chains
- Better error messages with file paths and context
- Reduced boilerplate code (118 Result<T, String> instances to migrate)
- Automatic error conversion for seamless integration
## Testing
- All existing tests pass (4/4)
- Compilation successful with no warnings
- Build time: 0.61s (no performance regression)
## Remaining Work
- claude_plugin.rs (7 functions)
- migration.rs, import_export.rs
- Add unit tests for error.rs
- Complete commands.rs migration after dependent modules
Co-authored-by: Claude <claude@anthropic.com>
2025-10-27 16:29:11 +08:00
|
|
|
|
pub fn save(&self) -> Result<(), AppError> {
|
2025-08-30 21:54:11 +08:00
|
|
|
|
let config_path = get_app_config_path();
|
2025-09-05 16:39:12 +08:00
|
|
|
|
// 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak,再写入新内容
|
|
|
|
|
|
if config_path.exists() {
|
|
|
|
|
|
let backup_path = get_app_config_dir().join("config.json.bak");
|
|
|
|
|
|
if let Err(e) = copy_file(&config_path, &backup_path) {
|
2025-11-12 10:47:34 +08:00
|
|
|
|
log::warn!("备份 config.json 到 .bak 失败: {e}");
|
2025-09-05 16:39:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
write_json_file(&config_path, self)?;
|
|
|
|
|
|
Ok(())
|
2025-08-30 21:54:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 获取指定应用的管理器
|
|
|
|
|
|
pub fn get_manager(&self, app: &AppType) -> Option<&ProviderManager> {
|
|
|
|
|
|
self.apps.get(app.as_str())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 获取指定应用的管理器(可变引用)
|
|
|
|
|
|
pub fn get_manager_mut(&mut self, app: &AppType) -> Option<&mut ProviderManager> {
|
|
|
|
|
|
self.apps.get_mut(app.as_str())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 确保应用存在
|
|
|
|
|
|
pub fn ensure_app(&mut self, app: &AppType) {
|
|
|
|
|
|
if !self.apps.contains_key(app.as_str()) {
|
|
|
|
|
|
self.apps
|
|
|
|
|
|
.insert(app.as_str().to_string(), ProviderManager::default());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-09 21:08:42 +08:00
|
|
|
|
|
2025-10-09 22:02:56 +08:00
|
|
|
|
/// 获取指定客户端的 MCP 配置(不可变引用)
|
|
|
|
|
|
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
|
|
|
|
|
|
match app {
|
|
|
|
|
|
AppType::Claude => &self.mcp.claude,
|
|
|
|
|
|
AppType::Codex => &self.mcp.codex,
|
2025-11-12 10:47:34 +08:00
|
|
|
|
AppType::Gemini => &self.mcp.gemini,
|
2025-10-09 22:02:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 获取指定客户端的 MCP 配置(可变引用)
|
|
|
|
|
|
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
|
|
|
|
|
|
match app {
|
|
|
|
|
|
AppType::Claude => &mut self.mcp.claude,
|
|
|
|
|
|
AppType::Codex => &mut self.mcp.codex,
|
2025-11-12 10:47:34 +08:00
|
|
|
|
AppType::Gemini => &mut self.mcp.gemini,
|
2025-10-09 22:02:56 +08:00
|
|
|
|
}
|
2025-10-09 21:08:42 +08:00
|
|
|
|
}
|
2025-11-13 15:15:58 +08:00
|
|
|
|
|
|
|
|
|
|
/// 创建默认配置并自动导入已存在的提示词文件
|
|
|
|
|
|
fn default_with_auto_import() -> Result<Self, AppError> {
|
|
|
|
|
|
log::info!("首次启动,创建默认配置并检测提示词文件");
|
2025-11-13 15:43:37 +08:00
|
|
|
|
|
2025-11-13 15:15:58 +08:00
|
|
|
|
let mut config = Self::default();
|
2025-11-13 15:43:37 +08:00
|
|
|
|
|
2025-11-13 15:15:58 +08:00
|
|
|
|
// 为每个应用尝试自动导入提示词
|
|
|
|
|
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
|
|
|
|
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
|
|
|
|
|
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
2025-11-13 15:43:37 +08:00
|
|
|
|
|
2025-11-13 15:15:58 +08:00
|
|
|
|
Ok(config)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-15 16:47:08 +08:00
|
|
|
|
/// 已存在配置文件时的 Prompt 自动导入逻辑
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 适用于「老版本已经生成过 config.json,但当时还没有 Prompt 功能」的升级场景。
|
|
|
|
|
|
/// 判定规则:
|
|
|
|
|
|
/// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户)
|
|
|
|
|
|
/// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 返回值:
|
|
|
|
|
|
/// - Ok(true) 表示至少有一个应用成功导入了提示词
|
|
|
|
|
|
/// - Ok(false) 表示无需导入或未导入任何内容
|
|
|
|
|
|
fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
|
|
|
|
|
|
// 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
|
|
|
|
|
|
if !self.prompts.claude.prompts.is_empty()
|
|
|
|
|
|
|| !self.prompts.codex.prompts.is_empty()
|
|
|
|
|
|
|| !self.prompts.gemini.prompts.is_empty()
|
|
|
|
|
|
{
|
|
|
|
|
|
return Ok(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log::info!(
|
|
|
|
|
|
"检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let mut imported = false;
|
|
|
|
|
|
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
|
|
|
|
|
// 复用已有的单应用导入逻辑
|
|
|
|
|
|
if Self::auto_import_prompt_if_exists(self, app)? {
|
|
|
|
|
|
imported = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(imported)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-13 15:15:58 +08:00
|
|
|
|
/// 检查并自动导入单个应用的提示词文件
|
2025-11-15 16:47:08 +08:00
|
|
|
|
///
|
|
|
|
|
|
/// 返回值:
|
|
|
|
|
|
/// - Ok(true) 表示成功导入了非空文件
|
|
|
|
|
|
/// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败)
|
|
|
|
|
|
fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<bool, AppError> {
|
2025-11-13 15:15:58 +08:00
|
|
|
|
let file_path = prompt_file_path(&app)?;
|
2025-11-13 15:43:37 +08:00
|
|
|
|
|
2025-11-13 15:15:58 +08:00
|
|
|
|
// 检查文件是否存在
|
|
|
|
|
|
if !file_path.exists() {
|
|
|
|
|
|
log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}");
|
2025-11-15 16:47:08 +08:00
|
|
|
|
return Ok(false);
|
2025-11-13 15:15:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 读取文件内容
|
|
|
|
|
|
let content = match std::fs::read_to_string(&file_path) {
|
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}");
|
2025-11-15 16:47:08 +08:00
|
|
|
|
return Ok(false); // 失败时不中断,继续处理其他应用
|
2025-11-13 15:15:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 检查内容是否为空
|
|
|
|
|
|
if content.trim().is_empty() {
|
|
|
|
|
|
log::debug!("提示词文件内容为空,跳过导入: {file_path:?}");
|
2025-11-15 16:47:08 +08:00
|
|
|
|
return Ok(false);
|
2025-11-13 15:15:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log::info!("发现提示词文件,自动导入: {file_path:?}");
|
|
|
|
|
|
|
|
|
|
|
|
// 创建提示词对象
|
|
|
|
|
|
let timestamp = std::time::SystemTime::now()
|
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.as_secs() as i64;
|
|
|
|
|
|
|
|
|
|
|
|
let id = format!("auto-imported-{timestamp}");
|
|
|
|
|
|
let prompt = crate::prompt::Prompt {
|
|
|
|
|
|
id: id.clone(),
|
|
|
|
|
|
name: format!(
|
2025-11-13 15:43:37 +08:00
|
|
|
|
"Auto-imported Prompt {}",
|
2025-11-13 15:15:58 +08:00
|
|
|
|
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
|
|
|
|
|
),
|
|
|
|
|
|
content,
|
2025-11-13 15:43:37 +08:00
|
|
|
|
description: Some("Automatically imported on first launch".to_string()),
|
2025-11-13 15:15:58 +08:00
|
|
|
|
enabled: true, // 自动启用
|
|
|
|
|
|
created_at: Some(timestamp),
|
|
|
|
|
|
updated_at: Some(timestamp),
|
|
|
|
|
|
};
|
2025-11-13 15:43:37 +08:00
|
|
|
|
|
2025-11-13 15:15:58 +08:00
|
|
|
|
// 插入到对应的应用配置中
|
|
|
|
|
|
let prompts = match app {
|
|
|
|
|
|
AppType::Claude => &mut config.prompts.claude.prompts,
|
|
|
|
|
|
AppType::Codex => &mut config.prompts.codex.prompts,
|
|
|
|
|
|
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
|
|
|
|
|
};
|
2025-11-13 15:43:37 +08:00
|
|
|
|
|
2025-11-13 15:15:58 +08:00
|
|
|
|
prompts.insert(id, prompt);
|
2025-11-13 15:43:37 +08:00
|
|
|
|
|
2025-11-13 15:15:58 +08:00
|
|
|
|
log::info!("自动导入完成: {}", app.as_str());
|
2025-11-15 16:47:08 +08:00
|
|
|
|
Ok(true)
|
2025-11-13 15:15:58 +08:00
|
|
|
|
}
|
2025-11-14 12:51:24 +08:00
|
|
|
|
|
|
|
|
|
|
/// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 迁移策略:
|
|
|
|
|
|
/// 1. 检查是否已经迁移(mcp.servers 是否存在)
|
|
|
|
|
|
/// 2. 收集所有应用的 MCP,按 ID 去重合并
|
|
|
|
|
|
/// 3. 生成统一的 McpServer 结构,标记应用到哪些客户端
|
|
|
|
|
|
/// 4. 清空旧的分应用配置
|
|
|
|
|
|
pub fn migrate_mcp_to_unified(&mut self) -> Result<bool, AppError> {
|
|
|
|
|
|
// 检查是否已经是新结构
|
|
|
|
|
|
if self.mcp.servers.is_some() {
|
|
|
|
|
|
log::debug!("MCP 配置已是统一结构,跳过迁移");
|
|
|
|
|
|
return Ok(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log::info!("检测到旧版 MCP 配置格式,开始迁移到 v3.7.0 统一结构...");
|
|
|
|
|
|
|
|
|
|
|
|
let mut unified_servers: HashMap<String, McpServer> = HashMap::new();
|
|
|
|
|
|
let mut conflicts = Vec::new();
|
|
|
|
|
|
|
|
|
|
|
|
// 收集所有应用的 MCP
|
|
|
|
|
|
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
|
|
|
|
|
let old_servers = match app {
|
|
|
|
|
|
AppType::Claude => &self.mcp.claude.servers,
|
|
|
|
|
|
AppType::Codex => &self.mcp.codex.servers,
|
|
|
|
|
|
AppType::Gemini => &self.mcp.gemini.servers,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
for (id, entry) in old_servers {
|
|
|
|
|
|
let enabled = entry
|
|
|
|
|
|
.get("enabled")
|
|
|
|
|
|
.and_then(|v| v.as_bool())
|
|
|
|
|
|
.unwrap_or(true);
|
|
|
|
|
|
|
|
|
|
|
|
if let Some(existing) = unified_servers.get_mut(id) {
|
|
|
|
|
|
// 该 ID 已存在,合并 apps 字段
|
|
|
|
|
|
existing.apps.set_enabled_for(&app, enabled);
|
|
|
|
|
|
|
|
|
|
|
|
// 检测配置冲突(同 ID 但配置不同)
|
|
|
|
|
|
if existing.server != *entry.get("server").unwrap_or(&serde_json::json!({})) {
|
|
|
|
|
|
conflicts.push(format!(
|
|
|
|
|
|
"MCP '{id}' 在 {} 和之前的应用中配置不同,将使用首次遇到的配置",
|
|
|
|
|
|
app.as_str()
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 首次遇到该 MCP,创建新条目
|
|
|
|
|
|
let name = entry
|
|
|
|
|
|
.get("name")
|
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
|
.unwrap_or(id)
|
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
|
|
let server = entry
|
|
|
|
|
|
.get("server")
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.unwrap_or(serde_json::json!({}));
|
|
|
|
|
|
|
|
|
|
|
|
let description = entry
|
|
|
|
|
|
.get("description")
|
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
|
.map(|s| s.to_string());
|
|
|
|
|
|
|
|
|
|
|
|
let homepage = entry
|
|
|
|
|
|
.get("homepage")
|
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
|
.map(|s| s.to_string());
|
|
|
|
|
|
|
|
|
|
|
|
let docs = entry
|
|
|
|
|
|
.get("docs")
|
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
|
.map(|s| s.to_string());
|
|
|
|
|
|
|
|
|
|
|
|
let tags = entry
|
|
|
|
|
|
.get("tags")
|
|
|
|
|
|
.and_then(|v| v.as_array())
|
|
|
|
|
|
.map(|arr| {
|
|
|
|
|
|
arr.iter()
|
|
|
|
|
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
|
|
let mut apps = McpApps::default();
|
|
|
|
|
|
apps.set_enabled_for(&app, enabled);
|
|
|
|
|
|
|
|
|
|
|
|
unified_servers.insert(
|
|
|
|
|
|
id.clone(),
|
|
|
|
|
|
McpServer {
|
|
|
|
|
|
id: id.clone(),
|
|
|
|
|
|
name,
|
|
|
|
|
|
server,
|
|
|
|
|
|
apps,
|
|
|
|
|
|
description,
|
|
|
|
|
|
homepage,
|
|
|
|
|
|
docs,
|
|
|
|
|
|
tags,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 记录冲突警告
|
|
|
|
|
|
if !conflicts.is_empty() {
|
|
|
|
|
|
log::warn!("MCP 迁移过程中检测到配置冲突:");
|
|
|
|
|
|
for conflict in &conflicts {
|
|
|
|
|
|
log::warn!(" - {conflict}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log::info!(
|
|
|
|
|
|
"MCP 迁移完成,共迁移 {} 个服务器{}",
|
|
|
|
|
|
unified_servers.len(),
|
|
|
|
|
|
if !conflicts.is_empty() {
|
|
|
|
|
|
format!("(存在 {} 个冲突)", conflicts.len())
|
|
|
|
|
|
} else {
|
|
|
|
|
|
String::new()
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 替换为新结构
|
|
|
|
|
|
self.mcp.servers = Some(unified_servers);
|
|
|
|
|
|
|
|
|
|
|
|
// 清空旧的分应用配置
|
|
|
|
|
|
self.mcp.claude = McpConfig::default();
|
|
|
|
|
|
self.mcp.codex = McpConfig::default();
|
|
|
|
|
|
self.mcp.gemini = McpConfig::default();
|
|
|
|
|
|
|
|
|
|
|
|
Ok(true)
|
|
|
|
|
|
}
|
2025-11-13 15:15:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use serial_test::serial;
|
|
|
|
|
|
use std::env;
|
|
|
|
|
|
use std::fs;
|
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
|
|
struct TempHome {
|
2025-11-13 15:43:37 +08:00
|
|
|
|
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
|
2025-11-13 15:15:58 +08:00
|
|
|
|
dir: TempDir,
|
|
|
|
|
|
original_home: Option<String>,
|
|
|
|
|
|
original_userprofile: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl TempHome {
|
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
|
let dir = TempDir::new().expect("failed to create temp home");
|
|
|
|
|
|
let original_home = env::var("HOME").ok();
|
|
|
|
|
|
let original_userprofile = env::var("USERPROFILE").ok();
|
|
|
|
|
|
|
|
|
|
|
|
env::set_var("HOME", dir.path());
|
|
|
|
|
|
env::set_var("USERPROFILE", dir.path());
|
|
|
|
|
|
|
|
|
|
|
|
Self {
|
|
|
|
|
|
dir,
|
|
|
|
|
|
original_home,
|
|
|
|
|
|
original_userprofile,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Drop for TempHome {
|
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
|
match &self.original_home {
|
|
|
|
|
|
Some(value) => env::set_var("HOME", value),
|
|
|
|
|
|
None => env::remove_var("HOME"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
match &self.original_userprofile {
|
|
|
|
|
|
Some(value) => env::set_var("USERPROFILE", value),
|
|
|
|
|
|
None => env::remove_var("USERPROFILE"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn write_prompt_file(app: AppType, content: &str) {
|
|
|
|
|
|
let path = crate::prompt_files::prompt_file_path(&app).expect("prompt path");
|
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
|
fs::create_dir_all(parent).expect("create parent dir");
|
|
|
|
|
|
}
|
|
|
|
|
|
fs::write(path, content).expect("write prompt");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
#[serial]
|
|
|
|
|
|
fn auto_imports_existing_prompt_when_config_missing() {
|
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
|
write_prompt_file(AppType::Claude, "# hello");
|
|
|
|
|
|
|
|
|
|
|
|
let config = MultiAppConfig::load().expect("load config");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(config.prompts.claude.prompts.len(), 1);
|
|
|
|
|
|
let prompt = config
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.claude
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.next()
|
|
|
|
|
|
.expect("prompt exists");
|
|
|
|
|
|
assert!(prompt.enabled);
|
|
|
|
|
|
assert_eq!(prompt.content, "# hello");
|
|
|
|
|
|
|
|
|
|
|
|
let config_path = crate::config::get_app_config_path();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
config_path.exists(),
|
|
|
|
|
|
"auto import should persist config to disk"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
#[serial]
|
|
|
|
|
|
fn skips_empty_prompt_files_during_import() {
|
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
|
write_prompt_file(AppType::Claude, " \n ");
|
|
|
|
|
|
|
|
|
|
|
|
let config = MultiAppConfig::load().expect("load config");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
config.prompts.claude.prompts.is_empty(),
|
|
|
|
|
|
"empty files must be ignored"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
#[serial]
|
|
|
|
|
|
fn auto_import_happens_only_once() {
|
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
|
write_prompt_file(AppType::Claude, "first version");
|
|
|
|
|
|
|
|
|
|
|
|
let first = MultiAppConfig::load().expect("load config");
|
|
|
|
|
|
assert_eq!(first.prompts.claude.prompts.len(), 1);
|
|
|
|
|
|
let claude_prompt = first
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.claude
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.next()
|
|
|
|
|
|
.expect("prompt exists")
|
|
|
|
|
|
.content
|
|
|
|
|
|
.clone();
|
|
|
|
|
|
assert_eq!(claude_prompt, "first version");
|
|
|
|
|
|
|
|
|
|
|
|
// 覆盖文件内容,但保留 config.json
|
|
|
|
|
|
write_prompt_file(AppType::Claude, "second version");
|
|
|
|
|
|
let second = MultiAppConfig::load().expect("load config again");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(second.prompts.claude.prompts.len(), 1);
|
|
|
|
|
|
let prompt = second
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.claude
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.next()
|
|
|
|
|
|
.expect("prompt exists");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
prompt.content, "first version",
|
|
|
|
|
|
"should not re-import when config already exists"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
#[serial]
|
|
|
|
|
|
fn auto_imports_gemini_prompt_on_first_launch() {
|
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
|
write_prompt_file(AppType::Gemini, "# Gemini Prompt\n\nTest content");
|
|
|
|
|
|
|
|
|
|
|
|
let config = MultiAppConfig::load().expect("load config");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(config.prompts.gemini.prompts.len(), 1);
|
|
|
|
|
|
let prompt = config
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.gemini
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.next()
|
|
|
|
|
|
.expect("gemini prompt exists");
|
|
|
|
|
|
assert!(prompt.enabled, "gemini prompt should be enabled");
|
|
|
|
|
|
assert_eq!(prompt.content, "# Gemini Prompt\n\nTest content");
|
2025-11-13 15:43:37 +08:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
prompt.description,
|
|
|
|
|
|
Some("Automatically imported on first launch".to_string())
|
|
|
|
|
|
);
|
2025-11-13 15:15:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
#[serial]
|
|
|
|
|
|
fn auto_imports_all_three_apps_prompts() {
|
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
|
write_prompt_file(AppType::Claude, "# Claude prompt");
|
|
|
|
|
|
write_prompt_file(AppType::Codex, "# Codex prompt");
|
|
|
|
|
|
write_prompt_file(AppType::Gemini, "# Gemini prompt");
|
|
|
|
|
|
|
|
|
|
|
|
let config = MultiAppConfig::load().expect("load config");
|
|
|
|
|
|
|
|
|
|
|
|
// 验证所有三个应用的提示词都被导入
|
|
|
|
|
|
assert_eq!(config.prompts.claude.prompts.len(), 1);
|
|
|
|
|
|
assert_eq!(config.prompts.codex.prompts.len(), 1);
|
|
|
|
|
|
assert_eq!(config.prompts.gemini.prompts.len(), 1);
|
|
|
|
|
|
|
|
|
|
|
|
// 验证所有提示词都被启用
|
2025-11-13 15:43:37 +08:00
|
|
|
|
assert!(
|
|
|
|
|
|
config
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.claude
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.next()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.enabled
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
config
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.codex
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.next()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.enabled
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
config
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.gemini
|
|
|
|
|
|
.prompts
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.next()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.enabled
|
|
|
|
|
|
);
|
2025-11-13 15:15:58 +08:00
|
|
|
|
}
|
2025-08-30 21:54:11 +08:00
|
|
|
|
}
|