mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
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
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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<String, AppError> {
|
||||||
|
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<Connection, AppError> {
|
||||||
|
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<Option<PathBuf>, 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::<Vec<_>>(),
|
||||||
|
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<String, AppError> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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<Vec<String>, 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<String, AppError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IndexMap<String, McpServer>, 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<String> = row.get(3)?;
|
||||||
|
let homepage: Option<String> = row.get(4)?;
|
||||||
|
let docs: Option<String> = 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//! 数据访问对象 (DAO) 模块
|
||||||
|
//!
|
||||||
|
//! 提供各类数据的 CRUD 操作。
|
||||||
|
|
||||||
|
mod mcp;
|
||||||
|
mod prompts;
|
||||||
|
mod providers;
|
||||||
|
mod settings;
|
||||||
|
mod skills;
|
||||||
|
|
||||||
|
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||||
@@ -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<IndexMap<String, Prompt>, 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<String> = row.get(3)?;
|
||||||
|
let enabled: bool = row.get(4)?;
|
||||||
|
let created_at: Option<i64> = row.get(5)?;
|
||||||
|
let updated_at: Option<i64> = 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IndexMap<String, Provider>, 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<String> = row.get(3)?;
|
||||||
|
let category: Option<String> = row.get(4)?;
|
||||||
|
let created_at: Option<i64> = row.get(5)?;
|
||||||
|
let sort_index: Option<usize> = row.get(6)?;
|
||||||
|
let notes: Option<String> = row.get(7)?;
|
||||||
|
let icon: Option<String> = row.get(8)?;
|
||||||
|
let icon_color: Option<String> = 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<i64> = 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<Option<String>, 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Option<String>, 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<Option<String>, AppError> {
|
||||||
|
self.get_setting(&format!("common_config_{app_type}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置通用配置片段
|
||||||
|
pub fn set_config_snippet(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
snippet: Option<String>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IndexMap<String, SkillState>, 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<Vec<SkillRepo>, 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<usize, AppError> {
|
||||||
|
// 检查是否已有仓库
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, crate::prompt::Prompt>,
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||||
|
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<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 初始化数据库连接并创建表
|
||||||
|
///
|
||||||
|
/// 数据库文件位于 `~/.cc-switch/cc-switch.db`
|
||||||
|
pub fn init() -> Result<Self, AppError> {
|
||||||
|
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<Self, AppError> {
|
||||||
|
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<bool, AppError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<i32, AppError> {
|
||||||
|
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<bool, AppError> {
|
||||||
|
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<bool, AppError> {
|
||||||
|
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<bool, AppError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>>(4).ok().flatten(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("column {table}.{column} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_default(default: &Option<String>) -> Option<String> {
|
||||||
|
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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user