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) } 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:?}" ); } }