From c3f2f0798d694c87c5a26fb4562efaeeb5952044 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 25 Nov 2025 11:56:08 +0800 Subject: [PATCH] refactor(database): split monolithic database.rs into modular structure Split the 1788-line database.rs into a well-organized module directory: - mod.rs (142 lines): Database struct and initialization - schema.rs (341 lines): Table creation and schema migrations - backup.rs (324 lines): SQL import/export and snapshot backup - migration.rs (240 lines): JSON to SQLite data migration - tests.rs (280 lines): Unit tests - dao/providers.rs (254 lines): Provider CRUD operations - dao/mcp.rs (97 lines): MCP server CRUD operations - dao/prompts.rs (88 lines): Prompt CRUD operations - dao/skills.rs (124 lines): Skills CRUD operations - dao/settings.rs (65 lines): Settings key-value storage Benefits: - Max file size reduced from 1788 to 341 lines - Clear separation of concerns (schema, backup, migration, DAOs) - Easier to navigate and maintain - All 107 tests passing with zero API changes --- src-tauri/src/database.rs | 1788 ----------------------- src-tauri/src/database/backup.rs | 324 ++++ src-tauri/src/database/dao/mcp.rs | 97 ++ src-tauri/src/database/dao/mod.rs | 11 + src-tauri/src/database/dao/prompts.rs | 88 ++ src-tauri/src/database/dao/providers.rs | 254 ++++ src-tauri/src/database/dao/settings.rs | 65 + src-tauri/src/database/dao/skills.rs | 124 ++ src-tauri/src/database/migration.rs | 240 +++ src-tauri/src/database/mod.rs | 142 ++ src-tauri/src/database/schema.rs | 341 +++++ src-tauri/src/database/tests.rs | 280 ++++ 12 files changed, 1966 insertions(+), 1788 deletions(-) delete mode 100644 src-tauri/src/database.rs create mode 100644 src-tauri/src/database/backup.rs create mode 100644 src-tauri/src/database/dao/mcp.rs create mode 100644 src-tauri/src/database/dao/mod.rs create mode 100644 src-tauri/src/database/dao/prompts.rs create mode 100644 src-tauri/src/database/dao/providers.rs create mode 100644 src-tauri/src/database/dao/settings.rs create mode 100644 src-tauri/src/database/dao/skills.rs create mode 100644 src-tauri/src/database/migration.rs create mode 100644 src-tauri/src/database/mod.rs create mode 100644 src-tauri/src/database/schema.rs create mode 100644 src-tauri/src/database/tests.rs diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs deleted file mode 100644 index 83edf2122..000000000 --- a/src-tauri/src/database.rs +++ /dev/null @@ -1,1788 +0,0 @@ -use crate::app_config::{McpApps, McpServer, MultiAppConfig}; -use crate::config::get_app_config_dir; -use crate::error::AppError; -use crate::prompt::Prompt; -use crate::provider::{Provider, ProviderMeta}; -use crate::services::skill::{SkillRepo, SkillState}; -use chrono::Utc; -use indexmap::IndexMap; -use rusqlite::backup::Backup; -use rusqlite::types::ValueRef; -use rusqlite::{params, Connection}; -use serde::Serialize; -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::Mutex; -use tempfile::NamedTempFile; - -/// 安全地序列化 JSON,避免 unwrap panic -fn to_json_string(value: &T) -> Result { - serde_json::to_string(value) - .map_err(|e| AppError::Config(format!("JSON serialization failed: {e}"))) -} - -/// 安全地获取 Mutex 锁,避免 unwrap panic -macro_rules! lock_conn { - ($mutex:expr) => { - $mutex - .lock() - .map_err(|e| AppError::Database(format!("Mutex lock failed: {}", e)))? - }; -} - -const DB_BACKUP_RETAIN: usize = 10; -const SCHEMA_VERSION: i32 = 1; - -pub struct Database { - // 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State)中共享 - // rusqlite::Connection 本身不是 Sync 的 - conn: Mutex, -} - -impl Database { - /// 初始化数据库连接并创建表 - pub fn init() -> Result { - let db_path = get_app_config_dir().join("cc-switch.db"); - - // 确保父目录存在 - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } - - let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?; - - // 启用外键约束 - conn.execute("PRAGMA foreign_keys = ON;", []) - .map_err(|e| AppError::Database(e.to_string()))?; - - let db = Self { - conn: Mutex::new(conn), - }; - db.create_tables()?; - db.apply_schema_migrations()?; - - Ok(db) - } - - /// 创建内存数据库(用于测试) - pub fn memory() -> Result { - let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?; - - // 启用外键约束 - conn.execute("PRAGMA foreign_keys = ON;", []) - .map_err(|e| AppError::Database(e.to_string()))?; - - let db = Self { - conn: Mutex::new(conn), - }; - db.create_tables()?; - - Ok(db) - } - - fn create_tables(&self) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - Self::create_tables_on_conn(&conn) - } - - fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> { - // 1. Providers 表 - conn.execute( - "CREATE TABLE IF NOT EXISTS providers ( - id TEXT NOT NULL, - app_type TEXT NOT NULL, - name TEXT NOT NULL, - settings_config TEXT NOT NULL, - website_url TEXT, - category TEXT, - created_at INTEGER, - sort_index INTEGER, - notes TEXT, - icon TEXT, - icon_color TEXT, - meta TEXT NOT NULL DEFAULT '{}', - is_current BOOLEAN NOT NULL DEFAULT 0, - PRIMARY KEY (id, app_type) - )", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - // 2. Provider Endpoints 表 - conn.execute( - "CREATE TABLE IF NOT EXISTS provider_endpoints ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider_id TEXT NOT NULL, - app_type TEXT NOT NULL, - url TEXT NOT NULL, - added_at INTEGER, - FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE - )", - [], - ).map_err(|e| AppError::Database(e.to_string()))?; - - // 3. MCP Servers 表 - conn.execute( - "CREATE TABLE IF NOT EXISTS mcp_servers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - server_config TEXT NOT NULL, - description TEXT, - homepage TEXT, - docs TEXT, - tags TEXT NOT NULL DEFAULT '[]', - enabled_claude BOOLEAN NOT NULL DEFAULT 0, - enabled_codex BOOLEAN NOT NULL DEFAULT 0, - enabled_gemini BOOLEAN NOT NULL DEFAULT 0 - )", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - // 4. Prompts 表 - conn.execute( - "CREATE TABLE IF NOT EXISTS prompts ( - id TEXT NOT NULL, - app_type TEXT NOT NULL, - name TEXT NOT NULL, - content TEXT NOT NULL, - description TEXT, - enabled BOOLEAN NOT NULL DEFAULT 1, - created_at INTEGER, - updated_at INTEGER, - PRIMARY KEY (id, app_type) - )", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - // 5. Skills 表 - conn.execute( - "CREATE TABLE IF NOT EXISTS skills ( - key TEXT PRIMARY KEY, - installed BOOLEAN NOT NULL DEFAULT 0, - installed_at INTEGER NOT NULL DEFAULT 0 - )", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - // 6. Skill Repos 表 - conn.execute( - "CREATE TABLE IF NOT EXISTS skill_repos ( - owner TEXT NOT NULL, - name TEXT NOT NULL, - branch TEXT NOT NULL DEFAULT 'main', - enabled BOOLEAN NOT NULL DEFAULT 1, - skills_path TEXT, - PRIMARY KEY (owner, name) - )", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - // 7. Settings 表 (通用配置) - conn.execute( - "CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT - )", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - Ok(()) - } - - fn get_user_version(conn: &Connection) -> Result { - conn.query_row("PRAGMA user_version;", [], |row| row.get(0)) - .map_err(|e| AppError::Database(format!("读取 user_version 失败: {e}"))) - } - - fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> { - if version < 0 { - return Err(AppError::Database( - "user_version 不能为负数".to_string(), - )); - } - let sql = format!("PRAGMA user_version = {version};"); - conn.execute(&sql, []) - .map_err(|e| AppError::Database(format!("写入 user_version 失败: {e}")))?; - Ok(()) - } - - fn apply_schema_migrations(&self) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - Self::apply_schema_migrations_on_conn(&conn) - } - - fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> { - if s.is_empty() { - return Err(AppError::Database(format!("{kind} 不能为空"))); - } - if !s - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_') - { - return Err(AppError::Database(format!( - "非法{kind}: {s},仅允许字母、数字和下划线" - ))); - } - Ok(()) - } - - fn table_exists(conn: &Connection, table: &str) -> Result { - Self::validate_identifier(table, "表名")?; - - let mut stmt = conn - .prepare("SELECT name FROM sqlite_master WHERE type='table'") - .map_err(|e| AppError::Database(format!("读取表名失败: {e}")))?; - let mut rows = stmt - .query([]) - .map_err(|e| AppError::Database(format!("查询表名失败: {e}")))?; - while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { - let name: String = row - .get(0) - .map_err(|e| AppError::Database(format!("解析表名失败: {e}")))?; - if name.eq_ignore_ascii_case(table) { - return Ok(true); - } - } - Ok(false) - } - - fn has_column(conn: &Connection, table: &str, column: &str) -> Result { - Self::validate_identifier(table, "表名")?; - Self::validate_identifier(column, "列名")?; - - let sql = format!("PRAGMA table_info(\"{table}\");"); - let mut stmt = conn - .prepare(&sql) - .map_err(|e| AppError::Database(format!("读取表结构失败: {e}")))?; - let mut rows = stmt - .query([]) - .map_err(|e| AppError::Database(format!("查询表结构失败: {e}")))?; - while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { - let name: String = row - .get(1) - .map_err(|e| AppError::Database(format!("读取列名失败: {e}")))?; - if name.eq_ignore_ascii_case(column) { - return Ok(true); - } - } - Ok(false) - } - - fn add_column_if_missing( - conn: &Connection, - table: &str, - column: &str, - definition: &str, - ) -> Result { - Self::validate_identifier(table, "表名")?; - Self::validate_identifier(column, "列名")?; - - if !Self::table_exists(conn, table)? { - return Err(AppError::Database(format!( - "表 {table} 不存在,无法添加列 {column}" - ))); - } - if Self::has_column(conn, table, column)? { - return Ok(false); - } - - let sql = format!("ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition};"); - conn.execute(&sql, []) - .map_err(|e| AppError::Database(format!("为表 {table} 添加列 {column} 失败: {e}")))?; - log::info!("已为表 {table} 添加缺失列 {column}"); - Ok(true) - } - - fn apply_schema_migrations_on_conn(conn: &Connection) -> Result<(), AppError> { - conn.execute("SAVEPOINT schema_migration;", []) - .map_err(|e| AppError::Database(format!("开启迁移 savepoint 失败: {e}")))?; - - let mut version = Self::get_user_version(conn)?; - - if version > SCHEMA_VERSION { - conn.execute("ROLLBACK TO schema_migration;", []).ok(); - conn.execute("RELEASE schema_migration;", []).ok(); - return Err(AppError::Database(format!( - "数据库版本过新({version}),当前应用仅支持 {SCHEMA_VERSION},请升级应用后再尝试。" - ))); - } - - let result = (|| { - while version < SCHEMA_VERSION { - match version { - 0 => { - log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)"); - Self::add_column_if_missing(conn, "providers", "category", "TEXT")?; - Self::add_column_if_missing(conn, "providers", "created_at", "INTEGER")?; - Self::add_column_if_missing(conn, "providers", "sort_index", "INTEGER")?; - Self::add_column_if_missing(conn, "providers", "notes", "TEXT")?; - Self::add_column_if_missing(conn, "providers", "icon", "TEXT")?; - Self::add_column_if_missing(conn, "providers", "icon_color", "TEXT")?; - Self::add_column_if_missing( - conn, - "providers", - "meta", - "TEXT NOT NULL DEFAULT '{}'", - )?; - Self::add_column_if_missing( - conn, - "providers", - "is_current", - "BOOLEAN NOT NULL DEFAULT 0", - )?; - - Self::add_column_if_missing( - conn, - "provider_endpoints", - "added_at", - "INTEGER", - )?; - - Self::add_column_if_missing(conn, "mcp_servers", "description", "TEXT")?; - Self::add_column_if_missing(conn, "mcp_servers", "homepage", "TEXT")?; - Self::add_column_if_missing(conn, "mcp_servers", "docs", "TEXT")?; - Self::add_column_if_missing( - conn, - "mcp_servers", - "tags", - "TEXT NOT NULL DEFAULT '[]'", - )?; - Self::add_column_if_missing( - conn, - "mcp_servers", - "enabled_codex", - "BOOLEAN NOT NULL DEFAULT 0", - )?; - Self::add_column_if_missing( - conn, - "mcp_servers", - "enabled_gemini", - "BOOLEAN NOT NULL DEFAULT 0", - )?; - - Self::add_column_if_missing(conn, "prompts", "description", "TEXT")?; - Self::add_column_if_missing( - conn, - "prompts", - "enabled", - "BOOLEAN NOT NULL DEFAULT 1", - )?; - Self::add_column_if_missing(conn, "prompts", "created_at", "INTEGER")?; - Self::add_column_if_missing(conn, "prompts", "updated_at", "INTEGER")?; - - Self::add_column_if_missing( - conn, - "skills", - "installed_at", - "INTEGER NOT NULL DEFAULT 0", - )?; - - Self::add_column_if_missing( - conn, - "skill_repos", - "branch", - "TEXT NOT NULL DEFAULT 'main'", - )?; - Self::add_column_if_missing( - conn, - "skill_repos", - "enabled", - "BOOLEAN NOT NULL DEFAULT 1", - )?; - Self::add_column_if_missing(conn, "skill_repos", "skills_path", "TEXT")?; - - Self::set_user_version(conn, SCHEMA_VERSION)?; - } - _ => { - return Err(AppError::Database(format!( - "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" - ))); - } - } - - version = Self::get_user_version(conn)?; - } - - Ok(()) - })(); - - match result { - Ok(_) => { - conn.execute("RELEASE schema_migration;", []).map_err(|e| { - AppError::Database(format!("提交迁移 savepoint 失败: {e}")) - })?; - Ok(()) - } - Err(e) => { - conn.execute("ROLLBACK TO schema_migration;", []).ok(); - conn.execute("RELEASE schema_migration;", []).ok(); - Err(e) - } - } - } - - /// 创建内存快照以避免长时间持有数据库锁 - fn snapshot_to_memory(&self) -> Result { - let conn = lock_conn!(self.conn); - let mut snapshot = - Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?; - - { - let backup = - Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?; - backup - .step(-1) - .map_err(|e| AppError::Database(e.to_string()))?; - } - - Ok(snapshot) - } - - /// 导出为 SQLite 兼容的 SQL 文本 - pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> { - let snapshot = self.snapshot_to_memory()?; - let dump = Self::dump_sql(&snapshot)?; - - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } - - crate::config::atomic_write(target_path, dump.as_bytes()) - } - - /// 从 SQL 文件导入,返回生成的备份 ID(若无备份则为空字符串) - pub fn import_sql(&self, source_path: &Path) -> Result { - if !source_path.exists() { - return Err(AppError::InvalidInput(format!( - "SQL 文件不存在: {}", - source_path.display() - ))); - } - - let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?; - let sql_content = Self::sanitize_import_sql(&sql_raw); - - // 导入前备份现有数据库 - let backup_path = self.backup_database_file()?; - - // 在临时数据库执行导入,确保失败不会污染主库 - let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext { - context: "创建临时数据库文件失败".to_string(), - source: e, - })?; - let temp_path = temp_file.path().to_path_buf(); - let temp_conn = - Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?; - - temp_conn - .execute_batch(&sql_content) - .map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?; - - // 补齐缺失表/索引并进行基础校验 - Self::create_tables_on_conn(&temp_conn)?; - Self::apply_schema_migrations_on_conn(&temp_conn)?; - Self::validate_basic_state(&temp_conn)?; - - // 使用 Backup 将临时库原子写回主库 - { - let mut main_conn = lock_conn!(self.conn); - let backup = Backup::new(&temp_conn, &mut main_conn) - .map_err(|e| AppError::Database(e.to_string()))?; - backup - .step(-1) - .map_err(|e| AppError::Database(e.to_string()))?; - } - - let backup_id = backup_path - .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) - .unwrap_or_default(); - - Ok(backup_id) - } - - /// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错 - fn sanitize_import_sql(sql: &str) -> String { - let mut cleaned = String::new(); - let lower_keyword = "sqlite_sequence"; - - for stmt in sql.split(';') { - let trimmed = stmt.trim(); - if trimmed.is_empty() { - continue; - } - - if trimmed.to_ascii_lowercase().contains(lower_keyword) { - continue; - } - - cleaned.push_str(trimmed); - cleaned.push_str(";\n"); - } - - cleaned - } - - /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) - fn backup_database_file(&self) -> Result, AppError> { - let db_path = get_app_config_dir().join("cc-switch.db"); - if !db_path.exists() { - return Ok(None); - } - - let backup_dir = db_path - .parent() - .ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))? - .join("backups"); - - fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; - - let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); - let backup_path = backup_dir.join(format!("{backup_id}.db")); - - { - let conn = lock_conn!(self.conn); - let mut dest_conn = - Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?; - let backup = Backup::new(&conn, &mut dest_conn) - .map_err(|e| AppError::Database(e.to_string()))?; - backup - .step(-1) - .map_err(|e| AppError::Database(e.to_string()))?; - } - - Self::cleanup_db_backups(&backup_dir)?; - Ok(Some(backup_path)) - } - - fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> { - let entries = match fs::read_dir(dir) { - Ok(iter) => iter - .filter_map(|entry| entry.ok()) - .filter(|entry| { - entry - .path() - .extension() - .map(|ext| ext == "db") - .unwrap_or(false) - }) - .collect::>(), - Err(_) => return Ok(()), - }; - - if entries.len() <= DB_BACKUP_RETAIN { - return Ok(()); - } - - let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN); - let mut sorted = entries; - sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok()); - - for entry in sorted.into_iter().take(remove_count) { - if let Err(err) = fs::remove_file(entry.path()) { - log::warn!("删除旧数据库备份失败 {}: {}", entry.path().display(), err); - } - } - Ok(()) - } - - fn validate_basic_state(conn: &Connection) -> Result<(), AppError> { - let provider_count: i64 = conn - .query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0)) - .map_err(|e| AppError::Database(e.to_string()))?; - let mcp_count: i64 = conn - .query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0)) - .map_err(|e| AppError::Database(e.to_string()))?; - - if provider_count == 0 && mcp_count == 0 { - return Err(AppError::Config( - "导入的 SQL 未包含有效的供应商或 MCP 数据".to_string(), - )); - } - Ok(()) - } - - fn dump_sql(conn: &Connection) -> Result { - let mut output = String::new(); - let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); - let user_version: i64 = conn - .query_row("PRAGMA user_version;", [], |row| row.get(0)) - .unwrap_or(0); - - output.push_str(&format!( - "-- CC Switch SQLite 导出\n-- 生成时间: {timestamp}\n-- user_version: {user_version}\n" - )); - output.push_str("PRAGMA foreign_keys=OFF;\n"); - output.push_str(&format!("PRAGMA user_version={user_version};\n")); - output.push_str("BEGIN TRANSACTION;\n"); - - // 导出 schema - let mut stmt = conn - .prepare( - "SELECT type, name, tbl_name, sql - FROM sqlite_master - WHERE sql NOT NULL AND type IN ('table','index','trigger','view') - ORDER BY type='table' DESC, name", - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - let mut tables = Vec::new(); - let mut rows = stmt - .query([]) - .map_err(|e| AppError::Database(e.to_string()))?; - while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { - let obj_type: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?; - let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?; - let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?; - - // 跳过 SQLite 内部对象(如 sqlite_sequence) - if name.starts_with("sqlite_") { - continue; - } - - output.push_str(&sql); - output.push_str(";\n"); - - if obj_type == "table" && !name.starts_with("sqlite_") { - tables.push(name); - } - } - - // 导出数据 - for table in tables { - let columns = Self::get_table_columns(conn, &table)?; - if columns.is_empty() { - continue; - } - - let mut stmt = conn - .prepare(&format!("SELECT * FROM \"{table}\"")) - .map_err(|e| AppError::Database(e.to_string()))?; - let mut rows = stmt - .query([]) - .map_err(|e| AppError::Database(e.to_string()))?; - - while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { - let mut values = Vec::with_capacity(columns.len()); - for idx in 0..columns.len() { - let value = row - .get_ref(idx) - .map_err(|e| AppError::Database(e.to_string()))?; - values.push(Self::format_sql_value(value)?); - } - - let cols = columns - .iter() - .map(|c| format!("\"{c}\"")) - .collect::>() - .join(", "); - output.push_str(&format!( - "INSERT INTO \"{table}\" ({cols}) VALUES ({});\n", - values.join(", ") - )); - } - } - - output.push_str("COMMIT;\nPRAGMA foreign_keys=ON;\n"); - Ok(output) - } - - fn get_table_columns(conn: &Connection, table: &str) -> Result, AppError> { - let mut stmt = conn - .prepare(&format!("PRAGMA table_info(\"{table}\")")) - .map_err(|e| AppError::Database(e.to_string()))?; - let iter = stmt - .query_map([], |row| row.get::<_, String>(1)) - .map_err(|e| AppError::Database(e.to_string()))?; - - let mut columns = Vec::new(); - for col in iter { - columns.push(col.map_err(|e| AppError::Database(e.to_string()))?); - } - Ok(columns) - } - - fn format_sql_value(value: ValueRef<'_>) -> Result { - match value { - ValueRef::Null => Ok("NULL".to_string()), - ValueRef::Integer(i) => Ok(i.to_string()), - ValueRef::Real(f) => Ok(f.to_string()), - ValueRef::Text(t) => { - let text = std::str::from_utf8(t) - .map_err(|e| AppError::Database(format!("文本字段不是有效的 UTF-8: {e}")))?; - let escaped = text.replace('\'', "''"); - Ok(format!("'{escaped}'")) - } - ValueRef::Blob(bytes) => { - let mut s = String::from("X'"); - for b in bytes { - use std::fmt::Write; - let _ = write!(&mut s, "{b:02X}"); - } - s.push('\''); - Ok(s) - } - } - } - - /// 从 MultiAppConfig 迁移数据 - pub fn migrate_from_json(&self, config: &MultiAppConfig) -> Result<(), AppError> { - let mut conn = lock_conn!(self.conn); - let tx = conn - .transaction() - .map_err(|e| AppError::Database(e.to_string()))?; - - Self::migrate_from_json_tx(&tx, config)?; - - tx.commit() - .map_err(|e| AppError::Database(format!("Commit migration failed: {e}")))?; - Ok(()) - } - - /// Run migration dry-run in memory for pre-deployment validation (no disk writes) - pub fn migrate_from_json_dry_run(config: &MultiAppConfig) -> Result<(), AppError> { - let mut conn = - Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?; - Self::create_tables_on_conn(&conn)?; - Self::apply_schema_migrations_on_conn(&conn)?; - - let tx = conn - .transaction() - .map_err(|e| AppError::Database(e.to_string()))?; - Self::migrate_from_json_tx(&tx, config)?; - - // Explicitly drop transaction without committing (in-memory DB discarded anyway) - drop(tx); - Ok(()) - } - - fn migrate_from_json_tx( - tx: &rusqlite::Transaction<'_>, - config: &MultiAppConfig, - ) -> Result<(), AppError> { - // 1. 迁移 Providers - for (app_key, manager) in &config.apps { - let app_type = app_key; // "claude", "codex", "gemini" - let current_id = &manager.current; - - for (id, provider) in &manager.providers { - let is_current = if id == current_id { 1 } else { 0 }; - - // 处理 meta 和 endpoints - let mut meta_clone = provider.meta.clone().unwrap_or_default(); - let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); - - tx.execute( - "INSERT OR REPLACE INTO providers ( - id, app_type, name, settings_config, website_url, category, - created_at, sort_index, notes, icon, icon_color, meta, is_current - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - params![ - id, - app_type, - provider.name, - to_json_string(&provider.settings_config)?, - provider.website_url, - provider.category, - provider.created_at, - provider.sort_index, - provider.notes, - provider.icon, - provider.icon_color, - to_json_string(&meta_clone)?, // 不含 endpoints 的 meta - is_current, - ], - ) - .map_err(|e| AppError::Database(format!("Migrate provider failed: {e}")))?; - - // 迁移 Endpoints - for (url, endpoint) in endpoints { - tx.execute( - "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) - VALUES (?1, ?2, ?3, ?4)", - params![id, app_type, url, endpoint.added_at], - ) - .map_err(|e| AppError::Database(format!("Migrate endpoint failed: {e}")))?; - } - } - } - - // 2. 迁移 MCP Servers - if let Some(servers) = &config.mcp.servers { - for (id, server) in servers { - tx.execute( - "INSERT OR REPLACE INTO mcp_servers ( - id, name, server_config, description, homepage, docs, tags, - enabled_claude, enabled_codex, enabled_gemini - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - id, - server.name, - to_json_string(&server.server)?, - server.description, - server.homepage, - server.docs, - to_json_string(&server.tags)?, - server.apps.claude, - server.apps.codex, - server.apps.gemini, - ], - ) - .map_err(|e| AppError::Database(format!("Migrate mcp server failed: {e}")))?; - } - } - - // 3. 迁移 Prompts - let migrate_prompts = - |prompts_map: &std::collections::HashMap, - app_type: &str| - -> Result<(), AppError> { - for (id, prompt) in prompts_map { - tx.execute( - "INSERT OR REPLACE INTO prompts ( - id, app_type, name, content, description, enabled, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - id, - app_type, - prompt.name, - prompt.content, - prompt.description, - prompt.enabled, - prompt.created_at, - prompt.updated_at, - ], - ) - .map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?; - } - Ok(()) - }; - - migrate_prompts(&config.prompts.claude.prompts, "claude")?; - migrate_prompts(&config.prompts.codex.prompts, "codex")?; - migrate_prompts(&config.prompts.gemini.prompts, "gemini")?; - - // 4. 迁移 Skills - for (key, state) in &config.skills.skills { - tx.execute( - "INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)", - params![key, state.installed, state.installed_at.timestamp()], - ) - .map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?; - } - - for repo in &config.skills.repos { - tx.execute( - "INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)", - params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path], - ).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?; - } - - // 5. 迁移 Common Config - if let Some(snippet) = &config.common_config_snippets.claude { - tx.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", - params!["common_config_claude", snippet], - ) - .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; - } - if let Some(snippet) = &config.common_config_snippets.codex { - tx.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", - params!["common_config_codex", snippet], - ) - .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; - } - if let Some(snippet) = &config.common_config_snippets.gemini { - tx.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", - params!["common_config_gemini", snippet], - ) - .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; - } - - Ok(()) - } - - /// 检查数据库是否为空(需要首次导入) - /// 通过检查是否有任何 MCP 服务器、提示词、Skills 仓库或供应商来判断 - pub fn is_empty_for_first_import(&self) -> Result { - let conn = lock_conn!(self.conn); - - // 检查是否有 MCP 服务器 - let mcp_count: i64 = conn - .query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0)) - .map_err(|e| AppError::Database(e.to_string()))?; - - // 检查是否有提示词 - let prompt_count: i64 = conn - .query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0)) - .map_err(|e| AppError::Database(e.to_string()))?; - - // 检查是否有 Skills 仓库 - let skill_repo_count: i64 = conn - .query_row("SELECT COUNT(*) FROM skill_repos", [], |row| row.get(0)) - .map_err(|e| AppError::Database(e.to_string()))?; - - // 检查是否有供应商 - let provider_count: i64 = conn - .query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0)) - .map_err(|e| AppError::Database(e.to_string()))?; - - // 如果四者都为 0,说明是空数据库 - Ok(mcp_count == 0 && prompt_count == 0 && skill_repo_count == 0 && provider_count == 0) - } - - // --- Providers DAO --- - - pub fn get_all_providers( - &self, - app_type: &str, - ) -> Result, AppError> { - let conn = lock_conn!(self.conn); - let mut stmt = conn.prepare( - "SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta - FROM providers WHERE app_type = ?1 - ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC" - ).map_err(|e| AppError::Database(e.to_string()))?; - - let provider_iter = stmt - .query_map(params![app_type], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let settings_config_str: String = row.get(2)?; - let website_url: Option = row.get(3)?; - let category: Option = row.get(4)?; - let created_at: Option = row.get(5)?; - let sort_index: Option = row.get(6)?; - let notes: Option = row.get(7)?; - let icon: Option = row.get(8)?; - let icon_color: Option = row.get(9)?; - let meta_str: String = row.get(10)?; - - let settings_config = - serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null); - let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default(); - - Ok(( - id, - Provider { - id: "".to_string(), // Placeholder, set below - name, - settings_config, - website_url, - category, - created_at, - sort_index, - notes, - meta: Some(meta), - icon, - icon_color, - }, - )) - }) - .map_err(|e| AppError::Database(e.to_string()))?; - - let mut providers = IndexMap::new(); - for provider_res in provider_iter { - let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?; - provider.id = id.clone(); - - // Load endpoints - let mut stmt_endpoints = conn.prepare( - "SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC" - ).map_err(|e| AppError::Database(e.to_string()))?; - - let endpoints_iter = stmt_endpoints - .query_map(params![id, app_type], |row| { - let url: String = row.get(0)?; - let added_at: Option = row.get(1)?; - Ok(( - url, - crate::settings::CustomEndpoint { - url: "".to_string(), - added_at: added_at.unwrap_or(0), - last_used: None, - }, - )) - }) - .map_err(|e| AppError::Database(e.to_string()))?; - - let mut custom_endpoints = HashMap::new(); - for ep_res in endpoints_iter { - let (url, mut ep) = ep_res.map_err(|e| AppError::Database(e.to_string()))?; - ep.url = url.clone(); - custom_endpoints.insert(url, ep); - } - - if let Some(meta) = &mut provider.meta { - meta.custom_endpoints = custom_endpoints; - } - - providers.insert(id, provider); - } - - Ok(providers) - } - - pub fn get_current_provider(&self, app_type: &str) -> Result, AppError> { - let conn = lock_conn!(self.conn); - let mut stmt = conn - .prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_current = 1 LIMIT 1") - .map_err(|e| AppError::Database(e.to_string()))?; - - let mut rows = stmt - .query(params![app_type]) - .map_err(|e| AppError::Database(e.to_string()))?; - - if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { - Ok(Some( - row.get(0).map_err(|e| AppError::Database(e.to_string()))?, - )) - } else { - Ok(None) - } - } - - pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> { - let mut conn = lock_conn!(self.conn); - let tx = conn - .transaction() - .map_err(|e| AppError::Database(e.to_string()))?; - - // Handle meta and endpoints - let mut meta_clone = provider.meta.clone().unwrap_or_default(); - let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); - - // Check if it exists to preserve is_current - let is_current: bool = tx - .query_row( - "SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2", - params![provider.id, app_type], - |row| row.get(0), - ) - .unwrap_or(false); - - tx.execute( - "INSERT OR REPLACE INTO providers ( - id, app_type, name, settings_config, website_url, category, - created_at, sort_index, notes, icon, icon_color, meta, is_current - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - params![ - provider.id, - app_type, - provider.name, - serde_json::to_string(&provider.settings_config).unwrap(), - provider.website_url, - provider.category, - provider.created_at, - provider.sort_index, - provider.notes, - provider.icon, - provider.icon_color, - serde_json::to_string(&meta_clone).unwrap(), - is_current, - ], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - // Sync endpoints: Delete all and re-insert - tx.execute( - "DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2", - params![provider.id, app_type], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - for (url, endpoint) in endpoints { - tx.execute( - "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) - VALUES (?1, ?2, ?3, ?4)", - params![provider.id, app_type, url, endpoint.added_at], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - } - - tx.commit().map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - conn.execute( - "DELETE FROM providers WHERE id = ?1 AND app_type = ?2", - params![id, app_type], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { - let mut conn = lock_conn!(self.conn); - let tx = conn - .transaction() - .map_err(|e| AppError::Database(e.to_string()))?; - - // Reset all to 0 - tx.execute( - "UPDATE providers SET is_current = 0 WHERE app_type = ?1", - params![app_type], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - // Set new current - tx.execute( - "UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2", - params![id, app_type], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - tx.commit().map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - pub fn add_custom_endpoint( - &self, - app_type: &str, - provider_id: &str, - url: &str, - ) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - let added_at = chrono::Utc::now().timestamp_millis(); - conn.execute( - "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES (?1, ?2, ?3, ?4)", - params![provider_id, app_type, url, added_at], - ).map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - pub fn remove_custom_endpoint( - &self, - app_type: &str, - provider_id: &str, - url: &str, - ) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - conn.execute( - "DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3", - params![provider_id, app_type, url], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - // --- MCP Servers DAO --- - - pub fn get_all_mcp_servers(&self) -> Result, AppError> { - let conn = lock_conn!(self.conn); - let mut stmt = conn.prepare( - "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini - FROM mcp_servers - ORDER BY name ASC, id ASC" - ).map_err(|e| AppError::Database(e.to_string()))?; - - let server_iter = stmt - .query_map([], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let server_config_str: String = row.get(2)?; - let description: Option = row.get(3)?; - let homepage: Option = row.get(4)?; - let docs: Option = row.get(5)?; - let tags_str: String = row.get(6)?; - let enabled_claude: bool = row.get(7)?; - let enabled_codex: bool = row.get(8)?; - let enabled_gemini: bool = row.get(9)?; - - let server = serde_json::from_str(&server_config_str).unwrap_or_default(); - let tags = serde_json::from_str(&tags_str).unwrap_or_default(); - - Ok(( - id.clone(), - McpServer { - id, - name, - server, - apps: McpApps { - claude: enabled_claude, - codex: enabled_codex, - gemini: enabled_gemini, - }, - description, - homepage, - docs, - tags, - }, - )) - }) - .map_err(|e| AppError::Database(e.to_string()))?; - - let mut servers = IndexMap::new(); - for server_res in server_iter { - let (id, server) = server_res.map_err(|e| AppError::Database(e.to_string()))?; - servers.insert(id, server); - } - Ok(servers) - } - - pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - conn.execute( - "INSERT OR REPLACE INTO mcp_servers ( - id, name, server_config, description, homepage, docs, tags, - enabled_claude, enabled_codex, enabled_gemini - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - server.id, - server.name, - serde_json::to_string(&server.server).unwrap(), - server.description, - server.homepage, - server.docs, - serde_json::to_string(&server.tags).unwrap(), - server.apps.claude, - server.apps.codex, - server.apps.gemini, - ], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - pub fn delete_mcp_server(&self, id: &str) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - conn.execute("DELETE FROM mcp_servers WHERE id = ?1", params![id]) - .map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - // --- Prompts DAO --- - - pub fn get_prompts(&self, app_type: &str) -> Result, AppError> { - let conn = lock_conn!(self.conn); - let mut stmt = conn - .prepare( - "SELECT id, name, content, description, enabled, created_at, updated_at - FROM prompts WHERE app_type = ?1 - ORDER BY created_at ASC, id ASC", - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - let prompt_iter = stmt - .query_map(params![app_type], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let content: String = row.get(2)?; - let description: Option = row.get(3)?; - let enabled: bool = row.get(4)?; - let created_at: Option = row.get(5)?; - let updated_at: Option = row.get(6)?; - - Ok(( - id.clone(), - Prompt { - id, - name, - content, - description, - enabled, - created_at, - updated_at, - }, - )) - }) - .map_err(|e| AppError::Database(e.to_string()))?; - - let mut prompts = IndexMap::new(); - for prompt_res in prompt_iter { - let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?; - prompts.insert(id, prompt); - } - Ok(prompts) - } - - pub fn save_prompt(&self, app_type: &str, prompt: &Prompt) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - conn.execute( - "INSERT OR REPLACE INTO prompts ( - id, app_type, name, content, description, enabled, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - prompt.id, - app_type, - prompt.name, - prompt.content, - prompt.description, - prompt.enabled, - prompt.created_at, - prompt.updated_at, - ], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - conn.execute( - "DELETE FROM prompts WHERE id = ?1 AND app_type = ?2", - params![id, app_type], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - // --- Skills DAO --- - - pub fn get_skills(&self) -> Result, AppError> { - let conn = lock_conn!(self.conn); - let mut stmt = conn - .prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC") - .map_err(|e| AppError::Database(e.to_string()))?; - - let skill_iter = stmt - .query_map([], |row| { - let key: String = row.get(0)?; - let installed: bool = row.get(1)?; - let installed_at_ts: i64 = row.get(2)?; - - let installed_at = - chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default(); - - Ok(( - key, - SkillState { - installed, - installed_at, - }, - )) - }) - .map_err(|e| AppError::Database(e.to_string()))?; - - let mut skills = IndexMap::new(); - for skill_res in skill_iter { - let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?; - skills.insert(key, skill); - } - Ok(skills) - } - - pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - conn.execute( - "INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)", - params![key, state.installed, state.installed_at.timestamp()], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - pub fn get_skill_repos(&self) -> Result, AppError> { - let conn = lock_conn!(self.conn); - let mut stmt = conn - .prepare("SELECT owner, name, branch, enabled, skills_path FROM skill_repos ORDER BY owner ASC, name ASC") - .map_err(|e| AppError::Database(e.to_string()))?; - - let repo_iter = stmt - .query_map([], |row| { - Ok(SkillRepo { - owner: row.get(0)?, - name: row.get(1)?, - branch: row.get(2)?, - enabled: row.get(3)?, - skills_path: row.get(4)?, - }) - }) - .map_err(|e| AppError::Database(e.to_string()))?; - - let mut repos = Vec::new(); - for repo_res in repo_iter { - repos.push(repo_res.map_err(|e| AppError::Database(e.to_string()))?); - } - Ok(repos) - } - - pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - conn.execute( - "INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)", - params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path], - ).map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - pub fn delete_skill_repo(&self, owner: &str, name: &str) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - conn.execute( - "DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2", - params![owner, name], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - /// 初始化默认的 Skill 仓库(首次启动时调用) - pub fn init_default_skill_repos(&self) -> Result { - // 检查是否已有仓库 - let existing = self.get_skill_repos()?; - if !existing.is_empty() { - return Ok(0); - } - - // 获取默认仓库列表 - let default_store = crate::services::skill::SkillStore::default(); - let mut count = 0; - - for repo in &default_store.repos { - self.save_skill_repo(repo)?; - count += 1; - } - - log::info!("初始化默认 Skill 仓库完成,共 {count} 个"); - Ok(count) - } - - // --- Settings DAO --- - - pub fn get_setting(&self, key: &str) -> Result, AppError> { - let conn = lock_conn!(self.conn); - let mut stmt = conn - .prepare("SELECT value FROM settings WHERE key = ?1") - .map_err(|e| AppError::Database(e.to_string()))?; - - let mut rows = stmt - .query(params![key]) - .map_err(|e| AppError::Database(e.to_string()))?; - - if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { - Ok(Some( - row.get(0).map_err(|e| AppError::Database(e.to_string()))?, - )) - } else { - Ok(None) - } - } - - pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - conn.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", - params![key, value], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - - // --- Config Snippets Helper Methods --- - - pub fn get_config_snippet(&self, app_type: &str) -> Result, AppError> { - self.get_setting(&format!("common_config_{app_type}")) - } - - pub fn set_config_snippet( - &self, - app_type: &str, - snippet: Option, - ) -> Result<(), AppError> { - let key = format!("common_config_{app_type}"); - if let Some(value) = snippet { - self.set_setting(&key, &value) - } else { - // Delete if None - let conn = lock_conn!(self.conn); - conn.execute("DELETE FROM settings WHERE key = ?1", params![key]) - .map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const LEGACY_SCHEMA_SQL: &str = r#" - CREATE TABLE providers ( - id TEXT NOT NULL, - app_type TEXT NOT NULL, - name TEXT NOT NULL, - settings_config TEXT NOT NULL, - PRIMARY KEY (id, app_type) - ); - CREATE TABLE provider_endpoints ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider_id TEXT NOT NULL, - app_type TEXT NOT NULL, - url TEXT NOT NULL - ); - CREATE TABLE mcp_servers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - server_config TEXT NOT NULL - ); - CREATE TABLE prompts ( - id TEXT NOT NULL, - app_type TEXT NOT NULL, - name TEXT NOT NULL, - content TEXT NOT NULL, - PRIMARY KEY (id, app_type) - ); - CREATE TABLE skills ( - key TEXT PRIMARY KEY, - installed BOOLEAN NOT NULL DEFAULT 0 - ); - CREATE TABLE skill_repos ( - owner TEXT NOT NULL, - name TEXT NOT NULL, - PRIMARY KEY (owner, name) - ); - CREATE TABLE settings ( - key TEXT PRIMARY KEY, - value TEXT - ); - "#; - - #[derive(Debug)] - struct ColumnInfo { - name: String, - r#type: String, - notnull: i64, - default: Option, - } - - fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo { - let mut stmt = conn - .prepare(&format!("PRAGMA table_info(\"{table}\");")) - .expect("prepare pragma"); - let mut rows = stmt.query([]).expect("query pragma"); - while let Some(row) = rows.next().expect("read row") { - let name: String = row.get(1).expect("name"); - if name.eq_ignore_ascii_case(column) { - return ColumnInfo { - name, - r#type: row.get::<_, String>(2).expect("type"), - notnull: row.get::<_, i64>(3).expect("notnull"), - default: row.get::<_, Option>(4).ok().flatten(), - }; - } - } - panic!("column {table}.{column} not found"); - } - - fn normalize_default(default: &Option) -> Option { - default - .as_ref() - .map(|s| s.trim_matches('\'').trim_matches('"').to_string()) - } - - #[test] - fn migration_sets_user_version_when_missing() { - let conn = Connection::open_in_memory().expect("open memory db"); - - Database::create_tables_on_conn(&conn).expect("create tables"); - assert_eq!( - Database::get_user_version(&conn).expect("read version before"), - 0 - ); - - Database::apply_schema_migrations_on_conn(&conn).expect("apply migration"); - - assert_eq!( - Database::get_user_version(&conn).expect("read version after"), - SCHEMA_VERSION - ); - } - - #[test] - fn migration_rejects_future_version() { - let conn = Connection::open_in_memory().expect("open memory db"); - Database::create_tables_on_conn(&conn).expect("create tables"); - Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version"); - - let err = Database::apply_schema_migrations_on_conn(&conn) - .expect_err("should reject higher version"); - assert!( - err.to_string().contains("数据库版本过新"), - "unexpected error: {err}" - ); - } - - #[test] - fn migration_adds_missing_columns_for_providers() { - let conn = Connection::open_in_memory().expect("open memory db"); - - // 创建旧版 providers 表,缺少新增列 - conn.execute_batch(LEGACY_SCHEMA_SQL) - .expect("seed old schema"); - - Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations"); - - // 验证关键新增列已补齐 - for (table, column) in [ - ("providers", "meta"), - ("providers", "is_current"), - ("provider_endpoints", "added_at"), - ("mcp_servers", "enabled_gemini"), - ("prompts", "updated_at"), - ("skills", "installed_at"), - ("skill_repos", "enabled"), - ] { - assert!( - Database::has_column(&conn, table, column).expect("check column"), - "{table}.{column} should exist after migration" - ); - } - - // 验证 meta 列约束保持一致 - let meta = get_column_info(&conn, "providers", "meta"); - assert_eq!(meta.notnull, 1, "meta should be NOT NULL"); - assert_eq!( - normalize_default(&meta.default).as_deref(), - Some("{}"), - "meta default should be '{{}}'" - ); - - assert_eq!( - Database::get_user_version(&conn).expect("version after migration"), - SCHEMA_VERSION - ); - } - - #[test] - fn migration_aligns_column_defaults_and_types() { - let conn = Connection::open_in_memory().expect("open memory db"); - conn.execute_batch(LEGACY_SCHEMA_SQL) - .expect("seed old schema"); - - Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations"); - - let is_current = get_column_info(&conn, "providers", "is_current"); - assert_eq!(is_current.r#type, "BOOLEAN"); - assert_eq!(is_current.notnull, 1); - assert_eq!( - normalize_default(&is_current.default).as_deref(), - Some("0") - ); - - let tags = get_column_info(&conn, "mcp_servers", "tags"); - assert_eq!(tags.r#type, "TEXT"); - assert_eq!(tags.notnull, 1); - assert_eq!(normalize_default(&tags.default).as_deref(), Some("[]")); - - let enabled = get_column_info(&conn, "prompts", "enabled"); - assert_eq!(enabled.r#type, "BOOLEAN"); - assert_eq!(enabled.notnull, 1); - assert_eq!( - normalize_default(&enabled.default).as_deref(), - Some("1") - ); - - let installed_at = get_column_info(&conn, "skills", "installed_at"); - assert_eq!(installed_at.r#type, "INTEGER"); - assert_eq!(installed_at.notnull, 1); - assert_eq!( - normalize_default(&installed_at.default).as_deref(), - Some("0") - ); - - let branch = get_column_info(&conn, "skill_repos", "branch"); - assert_eq!(branch.r#type, "TEXT"); - assert_eq!(normalize_default(&branch.default).as_deref(), Some("main")); - - let skill_repo_enabled = get_column_info(&conn, "skill_repos", "enabled"); - assert_eq!(skill_repo_enabled.r#type, "BOOLEAN"); - assert_eq!(skill_repo_enabled.notnull, 1); - assert_eq!( - normalize_default(&skill_repo_enabled.default).as_deref(), - Some("1") - ); - } - - #[test] - fn dry_run_does_not_write_to_disk() { - use crate::app_config::MultiAppConfig; - use crate::provider::ProviderManager; - use std::collections::HashMap; - - // Create minimal valid config for migration - let mut apps = HashMap::new(); - apps.insert("claude".to_string(), ProviderManager::default()); - - let config = MultiAppConfig { - version: 2, - apps, - mcp: Default::default(), - prompts: Default::default(), - skills: Default::default(), - common_config_snippets: Default::default(), - claude_common_config_snippet: None, - }; - - // Dry-run should succeed without any file I/O errors - let result = Database::migrate_from_json_dry_run(&config); - assert!( - result.is_ok(), - "Dry-run should succeed with valid config: {result:?}" - ); - - // Verify dry-run can detect schema errors early - // (This would fail if migrate_from_json_tx had incompatible SQL) - } - - #[test] - fn dry_run_validates_schema_compatibility() { - use crate::app_config::MultiAppConfig; - use crate::provider::{Provider, ProviderManager}; - use indexmap::IndexMap; - use serde_json::json; - - // Create config with actual provider data - let mut providers = IndexMap::new(); - providers.insert( - "test-provider".to_string(), - Provider { - id: "test-provider".to_string(), - name: "Test Provider".to_string(), - settings_config: json!({ - "anthropicApiKey": "sk-test-123", - }), - website_url: None, - category: None, - created_at: Some(1234567890), - sort_index: None, - notes: None, - meta: None, - icon: None, - icon_color: None, - }, - ); - - let mut manager = ProviderManager::default(); - manager.providers = providers; - manager.current = "test-provider".to_string(); - - let mut apps = HashMap::new(); - apps.insert("claude".to_string(), manager); - - let config = MultiAppConfig { - version: 2, - apps, - mcp: Default::default(), - prompts: Default::default(), - skills: Default::default(), - common_config_snippets: Default::default(), - claude_common_config_snippet: None, - }; - - // Dry-run should validate the full migration path - let result = Database::migrate_from_json_dry_run(&config); - assert!( - result.is_ok(), - "Dry-run should succeed with provider data: {result:?}" - ); - } -} diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs new file mode 100644 index 000000000..631bc30e7 --- /dev/null +++ b/src-tauri/src/database/backup.rs @@ -0,0 +1,324 @@ +//! 数据库备份和恢复 +//! +//! 提供 SQL 导出/导入和二进制快照备份功能。 + +use super::{lock_conn, Database, DB_BACKUP_RETAIN}; +use crate::config::get_app_config_dir; +use crate::error::AppError; +use chrono::Utc; +use rusqlite::backup::Backup; +use rusqlite::types::ValueRef; +use rusqlite::Connection; +use std::fs; +use std::path::{Path, PathBuf}; +use tempfile::NamedTempFile; + +impl Database { + /// 导出为 SQLite 兼容的 SQL 文本 + pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> { + let snapshot = self.snapshot_to_memory()?; + let dump = Self::dump_sql(&snapshot)?; + + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + crate::config::atomic_write(target_path, dump.as_bytes()) + } + + /// 从 SQL 文件导入,返回生成的备份 ID(若无备份则为空字符串) + pub fn import_sql(&self, source_path: &Path) -> Result { + if !source_path.exists() { + return Err(AppError::InvalidInput(format!( + "SQL 文件不存在: {}", + source_path.display() + ))); + } + + let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?; + let sql_content = Self::sanitize_import_sql(&sql_raw); + + // 导入前备份现有数据库 + let backup_path = self.backup_database_file()?; + + // 在临时数据库执行导入,确保失败不会污染主库 + let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext { + context: "创建临时数据库文件失败".to_string(), + source: e, + })?; + let temp_path = temp_file.path().to_path_buf(); + let temp_conn = + Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?; + + temp_conn + .execute_batch(&sql_content) + .map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?; + + // 补齐缺失表/索引并进行基础校验 + Self::create_tables_on_conn(&temp_conn)?; + Self::apply_schema_migrations_on_conn(&temp_conn)?; + Self::validate_basic_state(&temp_conn)?; + + // 使用 Backup 将临时库原子写回主库 + { + let mut main_conn = lock_conn!(self.conn); + let backup = Backup::new(&temp_conn, &mut main_conn) + .map_err(|e| AppError::Database(e.to_string()))?; + backup + .step(-1) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + let backup_id = backup_path + .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) + .unwrap_or_default(); + + Ok(backup_id) + } + + /// 创建内存快照以避免长时间持有数据库锁 + pub(crate) fn snapshot_to_memory(&self) -> Result { + let conn = lock_conn!(self.conn); + let mut snapshot = + Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?; + + { + let backup = + Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?; + backup + .step(-1) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + Ok(snapshot) + } + + /// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错 + fn sanitize_import_sql(sql: &str) -> String { + let mut cleaned = String::new(); + let lower_keyword = "sqlite_sequence"; + + for stmt in sql.split(';') { + let trimmed = stmt.trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed.to_ascii_lowercase().contains(lower_keyword) { + continue; + } + + cleaned.push_str(trimmed); + cleaned.push_str(";\n"); + } + + cleaned + } + + /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) + fn backup_database_file(&self) -> Result, AppError> { + let db_path = get_app_config_dir().join("cc-switch.db"); + if !db_path.exists() { + return Ok(None); + } + + let backup_dir = db_path + .parent() + .ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))? + .join("backups"); + + fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + + let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); + let backup_path = backup_dir.join(format!("{backup_id}.db")); + + { + let conn = lock_conn!(self.conn); + let mut dest_conn = + Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?; + let backup = Backup::new(&conn, &mut dest_conn) + .map_err(|e| AppError::Database(e.to_string()))?; + backup + .step(-1) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + Self::cleanup_db_backups(&backup_dir)?; + Ok(Some(backup_path)) + } + + /// 清理旧的数据库备份,保留最新的 N 个 + fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> { + let entries = match fs::read_dir(dir) { + Ok(iter) => iter + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .path() + .extension() + .map(|ext| ext == "db") + .unwrap_or(false) + }) + .collect::>(), + Err(_) => return Ok(()), + }; + + if entries.len() <= DB_BACKUP_RETAIN { + return Ok(()); + } + + let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN); + let mut sorted = entries; + sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok()); + + for entry in sorted.into_iter().take(remove_count) { + if let Err(err) = fs::remove_file(entry.path()) { + log::warn!("删除旧数据库备份失败 {}: {}", entry.path().display(), err); + } + } + Ok(()) + } + + /// 基础状态校验 + fn validate_basic_state(conn: &Connection) -> Result<(), AppError> { + let provider_count: i64 = conn + .query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + let mcp_count: i64 = conn + .query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + + if provider_count == 0 && mcp_count == 0 { + return Err(AppError::Config( + "导入的 SQL 未包含有效的供应商或 MCP 数据".to_string(), + )); + } + Ok(()) + } + + /// 导出数据库为 SQL 文本 + fn dump_sql(conn: &Connection) -> Result { + let mut output = String::new(); + let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let user_version: i64 = conn + .query_row("PRAGMA user_version;", [], |row| row.get(0)) + .unwrap_or(0); + + output.push_str(&format!( + "-- CC Switch SQLite 导出\n-- 生成时间: {timestamp}\n-- user_version: {user_version}\n" + )); + output.push_str("PRAGMA foreign_keys=OFF;\n"); + output.push_str(&format!("PRAGMA user_version={user_version};\n")); + output.push_str("BEGIN TRANSACTION;\n"); + + // 导出 schema + let mut stmt = conn + .prepare( + "SELECT type, name, tbl_name, sql + FROM sqlite_master + WHERE sql NOT NULL AND type IN ('table','index','trigger','view') + ORDER BY type='table' DESC, name", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut tables = Vec::new(); + let mut rows = stmt + .query([]) + .map_err(|e| AppError::Database(e.to_string()))?; + while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { + let obj_type: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?; + let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?; + let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?; + + // 跳过 SQLite 内部对象(如 sqlite_sequence) + if name.starts_with("sqlite_") { + continue; + } + + output.push_str(&sql); + output.push_str(";\n"); + + if obj_type == "table" && !name.starts_with("sqlite_") { + tables.push(name); + } + } + + // 导出数据 + for table in tables { + let columns = Self::get_table_columns(conn, &table)?; + if columns.is_empty() { + continue; + } + + let mut stmt = conn + .prepare(&format!("SELECT * FROM \"{table}\"")) + .map_err(|e| AppError::Database(e.to_string()))?; + let mut rows = stmt + .query([]) + .map_err(|e| AppError::Database(e.to_string()))?; + + while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { + let mut values = Vec::with_capacity(columns.len()); + for idx in 0..columns.len() { + let value = row + .get_ref(idx) + .map_err(|e| AppError::Database(e.to_string()))?; + values.push(Self::format_sql_value(value)?); + } + + let cols = columns + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "); + output.push_str(&format!( + "INSERT INTO \"{table}\" ({cols}) VALUES ({});\n", + values.join(", ") + )); + } + } + + output.push_str("COMMIT;\nPRAGMA foreign_keys=ON;\n"); + Ok(output) + } + + /// 获取表的列名列表 + fn get_table_columns(conn: &Connection, table: &str) -> Result, AppError> { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info(\"{table}\")")) + .map_err(|e| AppError::Database(e.to_string()))?; + let iter = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut columns = Vec::new(); + for col in iter { + columns.push(col.map_err(|e| AppError::Database(e.to_string()))?); + } + Ok(columns) + } + + /// 格式化 SQL 值 + fn format_sql_value(value: ValueRef<'_>) -> Result { + match value { + ValueRef::Null => Ok("NULL".to_string()), + ValueRef::Integer(i) => Ok(i.to_string()), + ValueRef::Real(f) => Ok(f.to_string()), + ValueRef::Text(t) => { + let text = std::str::from_utf8(t) + .map_err(|e| AppError::Database(format!("文本字段不是有效的 UTF-8: {e}")))?; + let escaped = text.replace('\'', "''"); + Ok(format!("'{escaped}'")) + } + ValueRef::Blob(bytes) => { + let mut s = String::from("X'"); + for b in bytes { + use std::fmt::Write; + let _ = write!(&mut s, "{b:02X}"); + } + s.push('\''); + Ok(s) + } + } + } +} diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs new file mode 100644 index 000000000..b9953da26 --- /dev/null +++ b/src-tauri/src/database/dao/mcp.rs @@ -0,0 +1,97 @@ +//! MCP 服务器数据访问对象 +//! +//! 提供 MCP 服务器的 CRUD 操作。 + +use crate::app_config::{McpApps, McpServer}; +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use indexmap::IndexMap; +use rusqlite::params; + +impl Database { + /// 获取所有 MCP 服务器 + pub fn get_all_mcp_servers(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn.prepare( + "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini + FROM mcp_servers + ORDER BY name ASC, id ASC" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let server_iter = stmt + .query_map([], |row| { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let server_config_str: String = row.get(2)?; + let description: Option = row.get(3)?; + let homepage: Option = row.get(4)?; + let docs: Option = row.get(5)?; + let tags_str: String = row.get(6)?; + let enabled_claude: bool = row.get(7)?; + let enabled_codex: bool = row.get(8)?; + let enabled_gemini: bool = row.get(9)?; + + let server = serde_json::from_str(&server_config_str).unwrap_or_default(); + let tags = serde_json::from_str(&tags_str).unwrap_or_default(); + + Ok(( + id.clone(), + McpServer { + id, + name, + server, + apps: McpApps { + claude: enabled_claude, + codex: enabled_codex, + gemini: enabled_gemini, + }, + description, + homepage, + docs, + tags, + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut servers = IndexMap::new(); + for server_res in server_iter { + let (id, server) = server_res.map_err(|e| AppError::Database(e.to_string()))?; + servers.insert(id, server); + } + Ok(servers) + } + + /// 保存 MCP 服务器 + pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "INSERT OR REPLACE INTO mcp_servers ( + id, name, server_config, description, homepage, docs, tags, + enabled_claude, enabled_codex, enabled_gemini + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + server.id, + server.name, + serde_json::to_string(&server.server).unwrap(), + server.description, + server.homepage, + server.docs, + serde_json::to_string(&server.tags).unwrap(), + server.apps.claude, + server.apps.codex, + server.apps.gemini, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 删除 MCP 服务器 + pub fn delete_mcp_server(&self, id: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute("DELETE FROM mcp_servers WHERE id = ?1", params![id]) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } +} diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs new file mode 100644 index 000000000..b263079a1 --- /dev/null +++ b/src-tauri/src/database/dao/mod.rs @@ -0,0 +1,11 @@ +//! 数据访问对象 (DAO) 模块 +//! +//! 提供各类数据的 CRUD 操作。 + +mod mcp; +mod prompts; +mod providers; +mod settings; +mod skills; + +// 所有 DAO 方法都通过 Database impl 提供,无需单独导出 diff --git a/src-tauri/src/database/dao/prompts.rs b/src-tauri/src/database/dao/prompts.rs new file mode 100644 index 000000000..1c274504f --- /dev/null +++ b/src-tauri/src/database/dao/prompts.rs @@ -0,0 +1,88 @@ +//! 提示词数据访问对象 +//! +//! 提供提示词(Prompt)的 CRUD 操作。 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::prompt::Prompt; +use indexmap::IndexMap; +use rusqlite::params; + +impl Database { + /// 获取指定应用类型的所有提示词 + pub fn get_prompts(&self, app_type: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT id, name, content, description, enabled, created_at, updated_at + FROM prompts WHERE app_type = ?1 + ORDER BY created_at ASC, id ASC", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let prompt_iter = stmt + .query_map(params![app_type], |row| { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let content: String = row.get(2)?; + let description: Option = row.get(3)?; + let enabled: bool = row.get(4)?; + let created_at: Option = row.get(5)?; + let updated_at: Option = row.get(6)?; + + Ok(( + id.clone(), + Prompt { + id, + name, + content, + description, + enabled, + created_at, + updated_at, + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut prompts = IndexMap::new(); + for prompt_res in prompt_iter { + let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?; + prompts.insert(id, prompt); + } + Ok(prompts) + } + + /// 保存提示词 + pub fn save_prompt(&self, app_type: &str, prompt: &Prompt) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "INSERT OR REPLACE INTO prompts ( + id, app_type, name, content, description, enabled, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + prompt.id, + app_type, + prompt.name, + prompt.content, + prompt.description, + prompt.enabled, + prompt.created_at, + prompt.updated_at, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 删除提示词 + pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM prompts WHERE id = ?1 AND app_type = ?2", + params![id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } +} diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs new file mode 100644 index 000000000..b8744cbb9 --- /dev/null +++ b/src-tauri/src/database/dao/providers.rs @@ -0,0 +1,254 @@ +//! 供应商数据访问对象 +//! +//! 提供供应商(Provider)的 CRUD 操作。 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::provider::{Provider, ProviderMeta}; +use indexmap::IndexMap; +use rusqlite::params; +use std::collections::HashMap; + +impl Database { + /// 获取指定应用类型的所有供应商 + pub fn get_all_providers( + &self, + app_type: &str, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn.prepare( + "SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta + FROM providers WHERE app_type = ?1 + ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let provider_iter = stmt + .query_map(params![app_type], |row| { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let settings_config_str: String = row.get(2)?; + let website_url: Option = row.get(3)?; + let category: Option = row.get(4)?; + let created_at: Option = row.get(5)?; + let sort_index: Option = row.get(6)?; + let notes: Option = row.get(7)?; + let icon: Option = row.get(8)?; + let icon_color: Option = row.get(9)?; + let meta_str: String = row.get(10)?; + + let settings_config = + serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null); + let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default(); + + Ok(( + id, + Provider { + id: "".to_string(), // Placeholder, set below + name, + settings_config, + website_url, + category, + created_at, + sort_index, + notes, + meta: Some(meta), + icon, + icon_color, + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut providers = IndexMap::new(); + for provider_res in provider_iter { + let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?; + provider.id = id.clone(); + + // 加载 endpoints + let mut stmt_endpoints = conn.prepare( + "SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let endpoints_iter = stmt_endpoints + .query_map(params![id, app_type], |row| { + let url: String = row.get(0)?; + let added_at: Option = row.get(1)?; + Ok(( + url, + crate::settings::CustomEndpoint { + url: "".to_string(), + added_at: added_at.unwrap_or(0), + last_used: None, + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut custom_endpoints = HashMap::new(); + for ep_res in endpoints_iter { + let (url, mut ep) = ep_res.map_err(|e| AppError::Database(e.to_string()))?; + ep.url = url.clone(); + custom_endpoints.insert(url, ep); + } + + if let Some(meta) = &mut provider.meta { + meta.custom_endpoints = custom_endpoints; + } + + providers.insert(id, provider); + } + + Ok(providers) + } + + /// 获取当前激活的供应商 ID + pub fn get_current_provider(&self, app_type: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_current = 1 LIMIT 1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut rows = stmt + .query(params![app_type]) + .map_err(|e| AppError::Database(e.to_string()))?; + + if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { + Ok(Some( + row.get(0).map_err(|e| AppError::Database(e.to_string()))?, + )) + } else { + Ok(None) + } + } + + /// 保存供应商(新增或更新) + pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|e| AppError::Database(e.to_string()))?; + + // 处理 meta 和 endpoints + let mut meta_clone = provider.meta.clone().unwrap_or_default(); + let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); + + // 检查是否存在以保留 is_current + let is_current: bool = tx + .query_row( + "SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2", + params![provider.id, app_type], + |row| row.get(0), + ) + .unwrap_or(false); + + tx.execute( + "INSERT OR REPLACE INTO providers ( + id, app_type, name, settings_config, website_url, category, + created_at, sort_index, notes, icon, icon_color, meta, is_current + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + provider.id, + app_type, + provider.name, + serde_json::to_string(&provider.settings_config).unwrap(), + provider.website_url, + provider.category, + provider.created_at, + provider.sort_index, + provider.notes, + provider.icon, + provider.icon_color, + serde_json::to_string(&meta_clone).unwrap(), + is_current, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 同步 endpoints:删除全部后重新插入 + tx.execute( + "DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2", + params![provider.id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + for (url, endpoint) in endpoints { + tx.execute( + "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) + VALUES (?1, ?2, ?3, ?4)", + params![provider.id, app_type, url, endpoint.added_at], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + tx.commit().map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 删除供应商 + pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM providers WHERE id = ?1 AND app_type = ?2", + params![id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 设置当前供应商 + pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|e| AppError::Database(e.to_string()))?; + + // 重置所有为 0 + tx.execute( + "UPDATE providers SET is_current = 0 WHERE app_type = ?1", + params![app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 设置新的当前供应商 + tx.execute( + "UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2", + params![id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + tx.commit().map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 添加自定义端点 + pub fn add_custom_endpoint( + &self, + app_type: &str, + provider_id: &str, + url: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + let added_at = chrono::Utc::now().timestamp_millis(); + conn.execute( + "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES (?1, ?2, ?3, ?4)", + params![provider_id, app_type, url, added_at], + ).map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 移除自定义端点 + pub fn remove_custom_endpoint( + &self, + app_type: &str, + provider_id: &str, + url: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3", + params![provider_id, app_type, url], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } +} diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs new file mode 100644 index 000000000..90272aabe --- /dev/null +++ b/src-tauri/src/database/dao/settings.rs @@ -0,0 +1,65 @@ +//! 通用设置数据访问对象 +//! +//! 提供键值对形式的通用设置存储。 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use rusqlite::params; + +impl Database { + /// 获取设置值 + pub fn get_setting(&self, key: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare("SELECT value FROM settings WHERE key = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut rows = stmt + .query(params![key]) + .map_err(|e| AppError::Database(e.to_string()))?; + + if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { + Ok(Some( + row.get(0).map_err(|e| AppError::Database(e.to_string()))?, + )) + } else { + Ok(None) + } + } + + /// 设置值 + pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + // --- Config Snippets 辅助方法 --- + + /// 获取通用配置片段 + pub fn get_config_snippet(&self, app_type: &str) -> Result, AppError> { + self.get_setting(&format!("common_config_{app_type}")) + } + + /// 设置通用配置片段 + pub fn set_config_snippet( + &self, + app_type: &str, + snippet: Option, + ) -> Result<(), AppError> { + let key = format!("common_config_{app_type}"); + if let Some(value) = snippet { + self.set_setting(&key, &value) + } else { + // 如果为 None 则删除 + let conn = lock_conn!(self.conn); + conn.execute("DELETE FROM settings WHERE key = ?1", params![key]) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + } +} diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs new file mode 100644 index 000000000..59c90d729 --- /dev/null +++ b/src-tauri/src/database/dao/skills.rs @@ -0,0 +1,124 @@ +//! Skills 数据访问对象 +//! +//! 提供 Skills 和 Skill Repos 的 CRUD 操作。 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::services::skill::{SkillRepo, SkillState}; +use indexmap::IndexMap; +use rusqlite::params; + +impl Database { + /// 获取所有 Skills 状态 + pub fn get_skills(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC") + .map_err(|e| AppError::Database(e.to_string()))?; + + let skill_iter = stmt + .query_map([], |row| { + let key: String = row.get(0)?; + let installed: bool = row.get(1)?; + let installed_at_ts: i64 = row.get(2)?; + + let installed_at = + chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default(); + + Ok(( + key, + SkillState { + installed, + installed_at, + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut skills = IndexMap::new(); + for skill_res in skill_iter { + let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?; + skills.insert(key, skill); + } + Ok(skills) + } + + /// 更新 Skill 状态 + pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)", + params![key, state.installed, state.installed_at.timestamp()], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 获取所有 Skill 仓库 + pub fn get_skill_repos(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare("SELECT owner, name, branch, enabled, skills_path FROM skill_repos ORDER BY owner ASC, name ASC") + .map_err(|e| AppError::Database(e.to_string()))?; + + let repo_iter = stmt + .query_map([], |row| { + Ok(SkillRepo { + owner: row.get(0)?, + name: row.get(1)?, + branch: row.get(2)?, + enabled: row.get(3)?, + skills_path: row.get(4)?, + }) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut repos = Vec::new(); + for repo_res in repo_iter { + repos.push(repo_res.map_err(|e| AppError::Database(e.to_string()))?); + } + Ok(repos) + } + + /// 保存 Skill 仓库 + pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)", + params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path], + ).map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 删除 Skill 仓库 + pub fn delete_skill_repo(&self, owner: &str, name: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2", + params![owner, name], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 初始化默认的 Skill 仓库(首次启动时调用) + pub fn init_default_skill_repos(&self) -> Result { + // 检查是否已有仓库 + let existing = self.get_skill_repos()?; + if !existing.is_empty() { + return Ok(0); + } + + // 获取默认仓库列表 + let default_store = crate::services::skill::SkillStore::default(); + let mut count = 0; + + for repo in &default_store.repos { + self.save_skill_repo(repo)?; + count += 1; + } + + log::info!("初始化默认 Skill 仓库完成,共 {count} 个"); + Ok(count) + } +} diff --git a/src-tauri/src/database/migration.rs b/src-tauri/src/database/migration.rs new file mode 100644 index 000000000..51c60e415 --- /dev/null +++ b/src-tauri/src/database/migration.rs @@ -0,0 +1,240 @@ +//! JSON → SQLite 数据迁移 +//! +//! 将旧版 config.json (MultiAppConfig) 数据迁移到 SQLite 数据库。 + +use super::{lock_conn, to_json_string, Database}; +use crate::app_config::MultiAppConfig; +use crate::error::AppError; +use rusqlite::{params, Connection}; + +impl Database { + /// 从 MultiAppConfig 迁移数据到数据库 + pub fn migrate_from_json(&self, config: &MultiAppConfig) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|e| AppError::Database(e.to_string()))?; + + Self::migrate_from_json_tx(&tx, config)?; + + tx.commit() + .map_err(|e| AppError::Database(format!("Commit migration failed: {e}")))?; + Ok(()) + } + + /// 运行迁移的 dry-run 模式(在内存数据库中验证,不写入磁盘) + /// + /// 用于部署前验证迁移逻辑是否正确。 + pub fn migrate_from_json_dry_run(config: &MultiAppConfig) -> Result<(), AppError> { + let mut conn = + Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?; + Self::create_tables_on_conn(&conn)?; + Self::apply_schema_migrations_on_conn(&conn)?; + + let tx = conn + .transaction() + .map_err(|e| AppError::Database(e.to_string()))?; + Self::migrate_from_json_tx(&tx, config)?; + + // 显式 drop transaction 而不提交(内存数据库会被丢弃) + drop(tx); + Ok(()) + } + + /// 在事务中执行迁移 + fn migrate_from_json_tx( + tx: &rusqlite::Transaction<'_>, + config: &MultiAppConfig, + ) -> Result<(), AppError> { + // 1. 迁移 Providers + Self::migrate_providers(tx, config)?; + + // 2. 迁移 MCP Servers + Self::migrate_mcp_servers(tx, config)?; + + // 3. 迁移 Prompts + Self::migrate_prompts(tx, config)?; + + // 4. 迁移 Skills + Self::migrate_skills(tx, config)?; + + // 5. 迁移 Common Config + Self::migrate_common_config(tx, config)?; + + Ok(()) + } + + /// 迁移供应商数据 + fn migrate_providers( + tx: &rusqlite::Transaction<'_>, + config: &MultiAppConfig, + ) -> Result<(), AppError> { + for (app_key, manager) in &config.apps { + let app_type = app_key; + let current_id = &manager.current; + + for (id, provider) in &manager.providers { + let is_current = if id == current_id { 1 } else { 0 }; + + // 处理 meta 和 endpoints + let mut meta_clone = provider.meta.clone().unwrap_or_default(); + let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); + + tx.execute( + "INSERT OR REPLACE INTO providers ( + id, app_type, name, settings_config, website_url, category, + created_at, sort_index, notes, icon, icon_color, meta, is_current + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + id, + app_type, + provider.name, + to_json_string(&provider.settings_config)?, + provider.website_url, + provider.category, + provider.created_at, + provider.sort_index, + provider.notes, + provider.icon, + provider.icon_color, + to_json_string(&meta_clone)?, + is_current, + ], + ) + .map_err(|e| AppError::Database(format!("Migrate provider failed: {e}")))?; + + // 迁移 Endpoints + for (url, endpoint) in endpoints { + tx.execute( + "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) + VALUES (?1, ?2, ?3, ?4)", + params![id, app_type, url, endpoint.added_at], + ) + .map_err(|e| AppError::Database(format!("Migrate endpoint failed: {e}")))?; + } + } + } + Ok(()) + } + + /// 迁移 MCP 服务器数据 + fn migrate_mcp_servers( + tx: &rusqlite::Transaction<'_>, + config: &MultiAppConfig, + ) -> Result<(), AppError> { + if let Some(servers) = &config.mcp.servers { + for (id, server) in servers { + tx.execute( + "INSERT OR REPLACE INTO mcp_servers ( + id, name, server_config, description, homepage, docs, tags, + enabled_claude, enabled_codex, enabled_gemini + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + id, + server.name, + to_json_string(&server.server)?, + server.description, + server.homepage, + server.docs, + to_json_string(&server.tags)?, + server.apps.claude, + server.apps.codex, + server.apps.gemini, + ], + ) + .map_err(|e| AppError::Database(format!("Migrate mcp server failed: {e}")))?; + } + } + Ok(()) + } + + /// 迁移提示词数据 + fn migrate_prompts( + tx: &rusqlite::Transaction<'_>, + config: &MultiAppConfig, + ) -> Result<(), AppError> { + let migrate_app_prompts = + |prompts_map: &std::collections::HashMap, + app_type: &str| + -> Result<(), AppError> { + for (id, prompt) in prompts_map { + tx.execute( + "INSERT OR REPLACE INTO prompts ( + id, app_type, name, content, description, enabled, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + id, + app_type, + prompt.name, + prompt.content, + prompt.description, + prompt.enabled, + prompt.created_at, + prompt.updated_at, + ], + ) + .map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?; + } + Ok(()) + }; + + migrate_app_prompts(&config.prompts.claude.prompts, "claude")?; + migrate_app_prompts(&config.prompts.codex.prompts, "codex")?; + migrate_app_prompts(&config.prompts.gemini.prompts, "gemini")?; + + Ok(()) + } + + /// 迁移 Skills 数据 + fn migrate_skills( + tx: &rusqlite::Transaction<'_>, + config: &MultiAppConfig, + ) -> Result<(), AppError> { + for (key, state) in &config.skills.skills { + tx.execute( + "INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)", + params![key, state.installed, state.installed_at.timestamp()], + ) + .map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?; + } + + for repo in &config.skills.repos { + tx.execute( + "INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)", + params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path], + ).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?; + } + + Ok(()) + } + + /// 迁移通用配置片段 + fn migrate_common_config( + tx: &rusqlite::Transaction<'_>, + config: &MultiAppConfig, + ) -> Result<(), AppError> { + if let Some(snippet) = &config.common_config_snippets.claude { + tx.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params!["common_config_claude", snippet], + ) + .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; + } + if let Some(snippet) = &config.common_config_snippets.codex { + tx.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params!["common_config_codex", snippet], + ) + .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; + } + if let Some(snippet) = &config.common_config_snippets.gemini { + tx.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params!["common_config_gemini", snippet], + ) + .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; + } + + Ok(()) + } +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs new file mode 100644 index 000000000..fdc612006 --- /dev/null +++ b/src-tauri/src/database/mod.rs @@ -0,0 +1,142 @@ +//! 数据库模块 - SQLite 数据持久化 +//! +//! 此模块提供应用的核心数据存储功能,包括: +//! - 供应商配置管理 +//! - MCP 服务器配置 +//! - 提示词管理 +//! - Skills 管理 +//! - 通用设置存储 +//! +//! ## 架构设计 +//! +//! ```text +//! database/ +//! ├── mod.rs - Database 结构体 + 初始化 +//! ├── schema.rs - 表结构定义 + Schema 迁移 +//! ├── backup.rs - SQL 导入导出 + 快照备份 +//! ├── migration.rs - JSON → SQLite 数据迁移 +//! └── dao/ - 数据访问对象 +//! ├── providers.rs +//! ├── mcp.rs +//! ├── prompts.rs +//! ├── skills.rs +//! └── settings.rs +//! ``` + +mod backup; +mod dao; +mod migration; +mod schema; + +#[cfg(test)] +mod tests; + +use crate::config::get_app_config_dir; +use crate::error::AppError; +use rusqlite::Connection; +use serde::Serialize; +use std::sync::Mutex; + +// DAO 方法通过 impl Database 提供,无需额外导出 + +/// 数据库备份保留数量 +const DB_BACKUP_RETAIN: usize = 10; + +/// 当前 Schema 版本号 +/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 +pub(crate) const SCHEMA_VERSION: i32 = 1; + +/// 安全地序列化 JSON,避免 unwrap panic +pub(crate) fn to_json_string(value: &T) -> Result { + serde_json::to_string(value) + .map_err(|e| AppError::Config(format!("JSON serialization failed: {e}"))) +} + +/// 安全地获取 Mutex 锁,避免 unwrap panic +macro_rules! lock_conn { + ($mutex:expr) => { + $mutex + .lock() + .map_err(|e| AppError::Database(format!("Mutex lock failed: {}", e)))? + }; +} + +// 导出宏供子模块使用 +pub(crate) use lock_conn; + +/// 数据库连接封装 +/// +/// 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State)中共享。 +/// rusqlite::Connection 本身不是 Sync 的,因此需要这层包装。 +pub struct Database { + pub(crate) conn: Mutex, +} + +impl Database { + /// 初始化数据库连接并创建表 + /// + /// 数据库文件位于 `~/.cc-switch/cc-switch.db` + pub fn init() -> Result { + let db_path = get_app_config_dir().join("cc-switch.db"); + + // 确保父目录存在 + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?; + + // 启用外键约束 + conn.execute("PRAGMA foreign_keys = ON;", []) + .map_err(|e| AppError::Database(e.to_string()))?; + + let db = Self { + conn: Mutex::new(conn), + }; + db.create_tables()?; + db.apply_schema_migrations()?; + + Ok(db) + } + + /// 创建内存数据库(用于测试) + pub fn memory() -> Result { + let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?; + + // 启用外键约束 + conn.execute("PRAGMA foreign_keys = ON;", []) + .map_err(|e| AppError::Database(e.to_string()))?; + + let db = Self { + conn: Mutex::new(conn), + }; + db.create_tables()?; + + Ok(db) + } + + /// 检查数据库是否为空(需要首次导入) + /// + /// 通过检查是否有任何 MCP 服务器、提示词、Skills 仓库或供应商来判断 + pub fn is_empty_for_first_import(&self) -> Result { + let conn = lock_conn!(self.conn); + + let mcp_count: i64 = conn + .query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + + let prompt_count: i64 = conn + .query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + + let skill_repo_count: i64 = conn + .query_row("SELECT COUNT(*) FROM skill_repos", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + + let provider_count: i64 = conn + .query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(mcp_count == 0 && prompt_count == 0 && skill_repo_count == 0 && provider_count == 0) + } +} diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs new file mode 100644 index 000000000..f0750aed9 --- /dev/null +++ b/src-tauri/src/database/schema.rs @@ -0,0 +1,341 @@ +//! Schema 定义和迁移 +//! +//! 负责数据库表结构的创建和版本迁移。 + +use super::{lock_conn, Database, SCHEMA_VERSION}; +use crate::error::AppError; +use rusqlite::Connection; + +impl Database { + /// 创建所有数据库表 + pub(crate) fn create_tables(&self) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + Self::create_tables_on_conn(&conn) + } + + /// 在指定连接上创建表(供迁移和测试使用) + pub(crate) fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> { + // 1. Providers 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS providers ( + id TEXT NOT NULL, + app_type TEXT NOT NULL, + name TEXT NOT NULL, + settings_config TEXT NOT NULL, + website_url TEXT, + category TEXT, + created_at INTEGER, + sort_index INTEGER, + notes TEXT, + icon TEXT, + icon_color TEXT, + meta TEXT NOT NULL DEFAULT '{}', + is_current BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (id, app_type) + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 2. Provider Endpoints 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS provider_endpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + url TEXT NOT NULL, + added_at INTEGER, + FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 3. MCP Servers 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS mcp_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + server_config TEXT NOT NULL, + description TEXT, + homepage TEXT, + docs TEXT, + tags TEXT NOT NULL DEFAULT '[]', + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0 + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 4. Prompts 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS prompts ( + id TEXT NOT NULL, + app_type TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT 1, + created_at INTEGER, + updated_at INTEGER, + PRIMARY KEY (id, app_type) + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 5. Skills 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS skills ( + key TEXT PRIMARY KEY, + installed BOOLEAN NOT NULL DEFAULT 0, + installed_at INTEGER NOT NULL DEFAULT 0 + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 6. Skill Repos 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS skill_repos ( + owner TEXT NOT NULL, + name TEXT NOT NULL, + branch TEXT NOT NULL DEFAULT 'main', + enabled BOOLEAN NOT NULL DEFAULT 1, + skills_path TEXT, + PRIMARY KEY (owner, name) + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 7. Settings 表 (通用配置) + conn.execute( + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + + /// 应用 Schema 迁移 + pub(crate) fn apply_schema_migrations(&self) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + Self::apply_schema_migrations_on_conn(&conn) + } + + /// 在指定连接上应用 Schema 迁移 + pub(crate) fn apply_schema_migrations_on_conn(conn: &Connection) -> Result<(), AppError> { + conn.execute("SAVEPOINT schema_migration;", []) + .map_err(|e| AppError::Database(format!("开启迁移 savepoint 失败: {e}")))?; + + let mut version = Self::get_user_version(conn)?; + + if version > SCHEMA_VERSION { + conn.execute("ROLLBACK TO schema_migration;", []).ok(); + conn.execute("RELEASE schema_migration;", []).ok(); + return Err(AppError::Database(format!( + "数据库版本过新({version}),当前应用仅支持 {SCHEMA_VERSION},请升级应用后再尝试。" + ))); + } + + let result = (|| { + while version < SCHEMA_VERSION { + match version { + 0 => { + log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)"); + Self::migrate_v0_to_v1(conn)?; + Self::set_user_version(conn, SCHEMA_VERSION)?; + } + _ => { + return Err(AppError::Database(format!( + "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" + ))); + } + } + version = Self::get_user_version(conn)?; + } + Ok(()) + })(); + + match result { + Ok(_) => { + conn.execute("RELEASE schema_migration;", []) + .map_err(|e| AppError::Database(format!("提交迁移 savepoint 失败: {e}")))?; + Ok(()) + } + Err(e) => { + conn.execute("ROLLBACK TO schema_migration;", []).ok(); + conn.execute("RELEASE schema_migration;", []).ok(); + Err(e) + } + } + } + + /// v0 -> v1 迁移:补齐所有缺失列 + fn migrate_v0_to_v1(conn: &Connection) -> Result<(), AppError> { + // providers 表 + Self::add_column_if_missing(conn, "providers", "category", "TEXT")?; + Self::add_column_if_missing(conn, "providers", "created_at", "INTEGER")?; + Self::add_column_if_missing(conn, "providers", "sort_index", "INTEGER")?; + Self::add_column_if_missing(conn, "providers", "notes", "TEXT")?; + Self::add_column_if_missing(conn, "providers", "icon", "TEXT")?; + Self::add_column_if_missing(conn, "providers", "icon_color", "TEXT")?; + Self::add_column_if_missing(conn, "providers", "meta", "TEXT NOT NULL DEFAULT '{}'")?; + Self::add_column_if_missing( + conn, + "providers", + "is_current", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + + // provider_endpoints 表 + Self::add_column_if_missing(conn, "provider_endpoints", "added_at", "INTEGER")?; + + // mcp_servers 表 + Self::add_column_if_missing(conn, "mcp_servers", "description", "TEXT")?; + Self::add_column_if_missing(conn, "mcp_servers", "homepage", "TEXT")?; + Self::add_column_if_missing(conn, "mcp_servers", "docs", "TEXT")?; + Self::add_column_if_missing(conn, "mcp_servers", "tags", "TEXT NOT NULL DEFAULT '[]'")?; + Self::add_column_if_missing( + conn, + "mcp_servers", + "enabled_codex", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + Self::add_column_if_missing( + conn, + "mcp_servers", + "enabled_gemini", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + + // prompts 表 + Self::add_column_if_missing(conn, "prompts", "description", "TEXT")?; + Self::add_column_if_missing(conn, "prompts", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?; + Self::add_column_if_missing(conn, "prompts", "created_at", "INTEGER")?; + Self::add_column_if_missing(conn, "prompts", "updated_at", "INTEGER")?; + + // skills 表 + Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?; + + // skill_repos 表 + Self::add_column_if_missing(conn, "skill_repos", "branch", "TEXT NOT NULL DEFAULT 'main'")?; + Self::add_column_if_missing( + conn, + "skill_repos", + "enabled", + "BOOLEAN NOT NULL DEFAULT 1", + )?; + Self::add_column_if_missing(conn, "skill_repos", "skills_path", "TEXT")?; + + Ok(()) + } + + // --- 辅助方法 --- + + pub(crate) fn get_user_version(conn: &Connection) -> Result { + conn.query_row("PRAGMA user_version;", [], |row| row.get(0)) + .map_err(|e| AppError::Database(format!("读取 user_version 失败: {e}"))) + } + + pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> { + if version < 0 { + return Err(AppError::Database( + "user_version 不能为负数".to_string(), + )); + } + let sql = format!("PRAGMA user_version = {version};"); + conn.execute(&sql, []) + .map_err(|e| AppError::Database(format!("写入 user_version 失败: {e}")))?; + Ok(()) + } + + fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> { + if s.is_empty() { + return Err(AppError::Database(format!("{kind} 不能为空"))); + } + if !s + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + return Err(AppError::Database(format!( + "非法{kind}: {s},仅允许字母、数字和下划线" + ))); + } + Ok(()) + } + + pub(crate) fn table_exists(conn: &Connection, table: &str) -> Result { + Self::validate_identifier(table, "表名")?; + + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .map_err(|e| AppError::Database(format!("读取表名失败: {e}")))?; + let mut rows = stmt + .query([]) + .map_err(|e| AppError::Database(format!("查询表名失败: {e}")))?; + while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { + let name: String = row + .get(0) + .map_err(|e| AppError::Database(format!("解析表名失败: {e}")))?; + if name.eq_ignore_ascii_case(table) { + return Ok(true); + } + } + Ok(false) + } + + pub(crate) fn has_column(conn: &Connection, table: &str, column: &str) -> Result { + Self::validate_identifier(table, "表名")?; + Self::validate_identifier(column, "列名")?; + + let sql = format!("PRAGMA table_info(\"{table}\");"); + let mut stmt = conn + .prepare(&sql) + .map_err(|e| AppError::Database(format!("读取表结构失败: {e}")))?; + let mut rows = stmt + .query([]) + .map_err(|e| AppError::Database(format!("查询表结构失败: {e}")))?; + while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { + let name: String = row + .get(1) + .map_err(|e| AppError::Database(format!("读取列名失败: {e}")))?; + if name.eq_ignore_ascii_case(column) { + return Ok(true); + } + } + Ok(false) + } + + fn add_column_if_missing( + conn: &Connection, + table: &str, + column: &str, + definition: &str, + ) -> Result { + Self::validate_identifier(table, "表名")?; + Self::validate_identifier(column, "列名")?; + + if !Self::table_exists(conn, table)? { + return Err(AppError::Database(format!( + "表 {table} 不存在,无法添加列 {column}" + ))); + } + if Self::has_column(conn, table, column)? { + return Ok(false); + } + + let sql = format!("ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition};"); + conn.execute(&sql, []) + .map_err(|e| AppError::Database(format!("为表 {table} 添加列 {column} 失败: {e}")))?; + log::info!("已为表 {table} 添加缺失列 {column}"); + Ok(true) + } +} diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs new file mode 100644 index 000000000..e76768471 --- /dev/null +++ b/src-tauri/src/database/tests.rs @@ -0,0 +1,280 @@ +//! 数据库模块测试 +//! +//! 包含 Schema 迁移和基本功能的测试。 + +use super::*; +use crate::app_config::MultiAppConfig; +use crate::provider::{Provider, ProviderManager}; +use indexmap::IndexMap; +use rusqlite::Connection; +use serde_json::json; +use std::collections::HashMap; + +const LEGACY_SCHEMA_SQL: &str = r#" + CREATE TABLE providers ( + id TEXT NOT NULL, + app_type TEXT NOT NULL, + name TEXT NOT NULL, + settings_config TEXT NOT NULL, + PRIMARY KEY (id, app_type) + ); + CREATE TABLE provider_endpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + url TEXT NOT NULL + ); + CREATE TABLE mcp_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + server_config TEXT NOT NULL + ); + CREATE TABLE prompts ( + id TEXT NOT NULL, + app_type TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + PRIMARY KEY (id, app_type) + ); + CREATE TABLE skills ( + key TEXT PRIMARY KEY, + installed BOOLEAN NOT NULL DEFAULT 0 + ); + CREATE TABLE skill_repos ( + owner TEXT NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (owner, name) + ); + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT + ); +"#; + +#[derive(Debug)] +struct ColumnInfo { + name: String, + r#type: String, + notnull: i64, + default: Option, +} + +fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info(\"{table}\");")) + .expect("prepare pragma"); + let mut rows = stmt.query([]).expect("query pragma"); + while let Some(row) = rows.next().expect("read row") { + let name: String = row.get(1).expect("name"); + if name.eq_ignore_ascii_case(column) { + return ColumnInfo { + name, + r#type: row.get::<_, String>(2).expect("type"), + notnull: row.get::<_, i64>(3).expect("notnull"), + default: row.get::<_, Option>(4).ok().flatten(), + }; + } + } + panic!("column {table}.{column} not found"); +} + +fn normalize_default(default: &Option) -> Option { + default + .as_ref() + .map(|s| s.trim_matches('\'').trim_matches('"').to_string()) +} + +#[test] +fn migration_sets_user_version_when_missing() { + let conn = Connection::open_in_memory().expect("open memory db"); + + Database::create_tables_on_conn(&conn).expect("create tables"); + assert_eq!( + Database::get_user_version(&conn).expect("read version before"), + 0 + ); + + Database::apply_schema_migrations_on_conn(&conn).expect("apply migration"); + + assert_eq!( + Database::get_user_version(&conn).expect("read version after"), + SCHEMA_VERSION + ); +} + +#[test] +fn migration_rejects_future_version() { + let conn = Connection::open_in_memory().expect("open memory db"); + Database::create_tables_on_conn(&conn).expect("create tables"); + Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version"); + + let err = Database::apply_schema_migrations_on_conn(&conn) + .expect_err("should reject higher version"); + assert!( + err.to_string().contains("数据库版本过新"), + "unexpected error: {err}" + ); +} + +#[test] +fn migration_adds_missing_columns_for_providers() { + let conn = Connection::open_in_memory().expect("open memory db"); + + // 创建旧版 providers 表,缺少新增列 + conn.execute_batch(LEGACY_SCHEMA_SQL) + .expect("seed old schema"); + + Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations"); + + // 验证关键新增列已补齐 + for (table, column) in [ + ("providers", "meta"), + ("providers", "is_current"), + ("provider_endpoints", "added_at"), + ("mcp_servers", "enabled_gemini"), + ("prompts", "updated_at"), + ("skills", "installed_at"), + ("skill_repos", "enabled"), + ] { + assert!( + Database::has_column(&conn, table, column).expect("check column"), + "{table}.{column} should exist after migration" + ); + } + + // 验证 meta 列约束保持一致 + let meta = get_column_info(&conn, "providers", "meta"); + assert_eq!(meta.notnull, 1, "meta should be NOT NULL"); + assert_eq!( + normalize_default(&meta.default).as_deref(), + Some("{}"), + "meta default should be '{{}}'" + ); + + assert_eq!( + Database::get_user_version(&conn).expect("version after migration"), + SCHEMA_VERSION + ); +} + +#[test] +fn migration_aligns_column_defaults_and_types() { + let conn = Connection::open_in_memory().expect("open memory db"); + conn.execute_batch(LEGACY_SCHEMA_SQL) + .expect("seed old schema"); + + Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations"); + + let is_current = get_column_info(&conn, "providers", "is_current"); + assert_eq!(is_current.r#type, "BOOLEAN"); + assert_eq!(is_current.notnull, 1); + assert_eq!( + normalize_default(&is_current.default).as_deref(), + Some("0") + ); + + let tags = get_column_info(&conn, "mcp_servers", "tags"); + assert_eq!(tags.r#type, "TEXT"); + assert_eq!(tags.notnull, 1); + assert_eq!(normalize_default(&tags.default).as_deref(), Some("[]")); + + let enabled = get_column_info(&conn, "prompts", "enabled"); + assert_eq!(enabled.r#type, "BOOLEAN"); + assert_eq!(enabled.notnull, 1); + assert_eq!( + normalize_default(&enabled.default).as_deref(), + Some("1") + ); + + let installed_at = get_column_info(&conn, "skills", "installed_at"); + assert_eq!(installed_at.r#type, "INTEGER"); + assert_eq!(installed_at.notnull, 1); + assert_eq!( + normalize_default(&installed_at.default).as_deref(), + Some("0") + ); + + let branch = get_column_info(&conn, "skill_repos", "branch"); + assert_eq!(branch.r#type, "TEXT"); + assert_eq!(normalize_default(&branch.default).as_deref(), Some("main")); + + let skill_repo_enabled = get_column_info(&conn, "skill_repos", "enabled"); + assert_eq!(skill_repo_enabled.r#type, "BOOLEAN"); + assert_eq!(skill_repo_enabled.notnull, 1); + assert_eq!( + normalize_default(&skill_repo_enabled.default).as_deref(), + Some("1") + ); +} + +#[test] +fn dry_run_does_not_write_to_disk() { + // Create minimal valid config for migration + let mut apps = HashMap::new(); + apps.insert("claude".to_string(), ProviderManager::default()); + + let config = MultiAppConfig { + version: 2, + apps, + mcp: Default::default(), + prompts: Default::default(), + skills: Default::default(), + common_config_snippets: Default::default(), + claude_common_config_snippet: None, + }; + + // Dry-run should succeed without any file I/O errors + let result = Database::migrate_from_json_dry_run(&config); + assert!( + result.is_ok(), + "Dry-run should succeed with valid config: {result:?}" + ); +} + +#[test] +fn dry_run_validates_schema_compatibility() { + // Create config with actual provider data + let mut providers = IndexMap::new(); + providers.insert( + "test-provider".to_string(), + Provider { + id: "test-provider".to_string(), + name: "Test Provider".to_string(), + settings_config: json!({ + "anthropicApiKey": "sk-test-123", + }), + website_url: None, + category: None, + created_at: Some(1234567890), + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + }, + ); + + let mut manager = ProviderManager::default(); + manager.providers = providers; + manager.current = "test-provider".to_string(); + + let mut apps = HashMap::new(); + apps.insert("claude".to_string(), manager); + + let config = MultiAppConfig { + version: 2, + apps, + mcp: Default::default(), + prompts: Default::default(), + skills: Default::default(), + common_config_snippets: Default::default(), + claude_common_config_snippet: None, + }; + + // Dry-run should validate the full migration path + let result = Database::migrate_from_json_dry_run(&config); + assert!( + result.is_ok(), + "Dry-run should succeed with provider data: {result:?}" + ); +}