Merge feat/sqlite-migration: add database schema migration system

This merge brings the SQLite migration system from feat/sqlite-migration branch:

## New Features
- Schema version control with SCHEMA_VERSION constant
- Automatic migration of missing columns for providers table
- Dry-run validation mode for schema compatibility checks
- JSON→SQLite migration feature gate (CC_SWITCH_ENABLE_JSON_DB_MIGRATION)
- Settings reload mechanism after imports

## Test Updates
- Updated tests to use SQLite database instead of config.json
- Removed obsolete import_config_from_path tests (replaced by db.import_sql)
- Fixed MCP tests to use unified McpServer structure (v3.7.0+)
- Updated provider switch tests to reflect no-backfill behavior
- Adjusted error type matching for new error variants
This commit is contained in:
Jason
2025-11-25 10:33:54 +08:00
10 changed files with 898 additions and 377 deletions
+5
View File
@@ -48,6 +48,11 @@ pub async fn import_config_from_file(
log::warn!("导入后同步 live 配置失败: {err}"); log::warn!("导入后同步 live 配置失败: {err}");
} }
// 重新加载设置到内存缓存,确保导入的设置生效
if let Err(err) = crate::settings::reload_settings() {
log::warn!("导入后重载设置失败: {err}");
}
Ok::<_, AppError>(json!({ Ok::<_, AppError>(json!({
"success": true, "success": true,
"message": "SQL imported successfully", "message": "SQL imported successfully",
+6 -1
View File
@@ -18,7 +18,12 @@ pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<boo
/// 重启应用程序(当 app_config_dir 变更后使用) /// 重启应用程序(当 app_config_dir 变更后使用)
#[tauri::command] #[tauri::command]
pub async fn restart_app(app: AppHandle) -> Result<bool, String> { pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
app.restart(); // 在后台延迟重启,让函数有时间返回响应
tauri::async_runtime::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
app.restart();
});
Ok(true)
} }
/// 获取 app_config_dir 覆盖配置 (从 Store) /// 获取 app_config_dir 覆盖配置 (从 Store)
+553 -6
View File
@@ -32,6 +32,7 @@ macro_rules! lock_conn {
} }
const DB_BACKUP_RETAIN: usize = 10; const DB_BACKUP_RETAIN: usize = 10;
const SCHEMA_VERSION: i32 = 1;
pub struct Database { pub struct Database {
// 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State)中共享 // 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State)中共享
@@ -59,6 +60,7 @@ impl Database {
conn: Mutex::new(conn), conn: Mutex::new(conn),
}; };
db.create_tables()?; db.create_tables()?;
db.apply_schema_migrations()?;
Ok(db) Ok(db)
} }
@@ -99,7 +101,7 @@ impl Database {
notes TEXT, notes TEXT,
icon TEXT, icon TEXT,
icon_color TEXT, icon_color TEXT,
meta TEXT, meta TEXT NOT NULL DEFAULT '{}',
is_current BOOLEAN NOT NULL DEFAULT 0, is_current BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id, app_type) PRIMARY KEY (id, app_type)
)", )",
@@ -129,7 +131,7 @@ impl Database {
description TEXT, description TEXT,
homepage TEXT, homepage TEXT,
docs TEXT, docs TEXT,
tags TEXT, tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0 enabled_gemini BOOLEAN NOT NULL DEFAULT 0
@@ -160,7 +162,7 @@ impl Database {
"CREATE TABLE IF NOT EXISTS skills ( "CREATE TABLE IF NOT EXISTS skills (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
installed BOOLEAN NOT NULL DEFAULT 0, installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER installed_at INTEGER NOT NULL DEFAULT 0
)", )",
[], [],
) )
@@ -171,7 +173,7 @@ impl Database {
"CREATE TABLE IF NOT EXISTS skill_repos ( "CREATE TABLE IF NOT EXISTS skill_repos (
owner TEXT NOT NULL, owner TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
branch TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main',
enabled BOOLEAN NOT NULL DEFAULT 1, enabled BOOLEAN NOT NULL DEFAULT 1,
skills_path TEXT, skills_path TEXT,
PRIMARY KEY (owner, name) PRIMARY KEY (owner, name)
@@ -193,6 +195,238 @@ impl Database {
Ok(()) Ok(())
} }
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}")))
}
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<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)
}
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)
}
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<Connection, AppError> { fn snapshot_to_memory(&self) -> Result<Connection, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
@@ -252,6 +486,7 @@ impl Database {
// 补齐缺失表/索引并进行基础校验 // 补齐缺失表/索引并进行基础校验
Self::create_tables_on_conn(&temp_conn)?; Self::create_tables_on_conn(&temp_conn)?;
Self::apply_schema_migrations_on_conn(&temp_conn)?;
Self::validate_basic_state(&temp_conn)?; Self::validate_basic_state(&temp_conn)?;
// 使用 Backup 将临时库原子写回主库 // 使用 Backup 将临时库原子写回主库
@@ -502,6 +737,34 @@ impl Database {
.transaction() .transaction()
.map_err(|e| AppError::Database(e.to_string()))?; .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 // 1. 迁移 Providers
for (app_key, manager) in &config.apps { for (app_key, manager) in &config.apps {
let app_type = app_key; // "claude", "codex", "gemini" let app_type = app_key; // "claude", "codex", "gemini"
@@ -643,8 +906,6 @@ impl Database {
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
} }
tx.commit()
.map_err(|e| AppError::Database(format!("Commit migration failed: {e}")))?;
Ok(()) Ok(())
} }
@@ -1239,3 +1500,289 @@ impl Database {
} }
} }
} }
#[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<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() {
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:?}"
);
}
}
+57 -13
View File
@@ -113,6 +113,25 @@ const TRAY_SECTIONS: [TrayAppSection; 3] = [
}, },
]; ];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum JsonMigrationMode {
Disabled,
DryRun,
Enabled,
}
/// 解析 JSON→DB 迁移模式:默认关闭,支持 dryrun/模拟演练
fn json_migration_mode() -> JsonMigrationMode {
match std::env::var("CC_SWITCH_ENABLE_JSON_DB_MIGRATION") {
Ok(val) => match val.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => JsonMigrationMode::Enabled,
"dryrun" | "dry-run" | "simulate" | "sim" => JsonMigrationMode::DryRun,
_ => JsonMigrationMode::Disabled,
},
Err(_) => JsonMigrationMode::Disabled,
}
}
fn append_provider_section<'a>( fn append_provider_section<'a>(
app: &'a tauri::AppHandle, app: &'a tauri::AppHandle,
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
@@ -542,8 +561,10 @@ pub fn run() {
let db_path = app_config_dir.join("cc-switch.db"); let db_path = app_config_dir.join("cc-switch.db");
let json_path = app_config_dir.join("config.json"); let json_path = app_config_dir.join("config.json");
// Check if migration is needed (DB doesn't exist but JSON does) // Check if config.json→SQLite migration needed (feature gated, disabled by default)
let migration_needed = !db_path.exists() && json_path.exists(); let migration_mode = json_migration_mode();
let has_json = json_path.exists();
let has_db = db_path.exists();
let db = match crate::database::Database::init() { let db = match crate::database::Database::init() {
Ok(db) => Arc::new(db), Ok(db) => Arc::new(db),
@@ -555,19 +576,42 @@ pub fn run() {
} }
}; };
if migration_needed { if !has_db && has_json {
log::info!("Starting migration from config.json to SQLite..."); match migration_mode {
match crate::app_config::MultiAppConfig::load() { JsonMigrationMode::Disabled => {
Ok(config) => { log::warn!(
if let Err(e) = db.migrate_from_json(&config) { "Detected config.json but migration is disabled by default. \
log::error!("Migration failed: {e}"); Set CC_SWITCH_ENABLE_JSON_DB_MIGRATION=1 to migrate, or =dryrun to validate first."
} else { );
log::info!("Migration successful"); }
// Optional: Rename config.json JsonMigrationMode::DryRun => {
// let _ = std::fs::rename(&json_path, json_path.with_extension("json.bak")); log::info!("Running migration dry-run (validation only, no disk writes)");
match crate::app_config::MultiAppConfig::load() {
Ok(config) => {
if let Err(e) = crate::database::Database::migrate_from_json_dry_run(&config) {
log::error!("Migration dry-run failed: {e}");
} else {
log::info!("Migration dry-run succeeded (no database written)");
}
}
Err(e) => log::error!("Failed to load config.json for dry-run: {e}"),
}
}
JsonMigrationMode::Enabled => {
log::info!("Starting migration from config.json to SQLite (user opt-in)");
match crate::app_config::MultiAppConfig::load() {
Ok(config) => {
if let Err(e) = db.migrate_from_json(&config) {
log::error!("Migration failed: {e}");
} else {
log::info!("Migration successful");
// Optional: Rename config.json to prevent re-migration
// let _ = std::fs::rename(&json_path, json_path.with_extension("json.migrated"));
}
}
Err(e) => log::error!("Failed to load config.json for migration: {e}"),
} }
} }
Err(e) => log::error!("Failed to load config.json for migration: {e}"),
} }
} }
+9
View File
@@ -260,6 +260,15 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
Ok(()) Ok(())
} }
/// 从数据库重新加载设置到内存缓存
/// 用于导入配置等场景,确保内存缓存与数据库同步
pub fn reload_settings() -> Result<(), AppError> {
let fresh_settings = load_initial_settings();
let mut guard = settings_store().write().expect("写入设置锁失败");
*guard = fresh_settings;
Ok(())
}
pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> { pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> {
let mut settings = get_settings(); let mut settings = get_settings();
let current = settings let current = settings
+64 -164
View File
@@ -1,15 +1,15 @@
use serde_json::json; use serde_json::json;
use std::{fs, path::Path, sync::RwLock}; use std::fs;
use tauri::async_runtime; use std::path::PathBuf;
use cc_switch_lib::{ use cc_switch_lib::{
get_claude_settings_path, read_json_file, AppError, AppState, AppType, ConfigService, get_claude_settings_path, read_json_file, AppError, AppType, ConfigService,
MultiAppConfig, Provider, ProviderMeta, MultiAppConfig, Provider, ProviderMeta,
}; };
#[path = "support.rs"] #[path = "support.rs"]
mod support; mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex}; use support::{create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
#[test] #[test]
fn sync_claude_provider_writes_live_settings() { fn sync_claude_provider_writes_live_settings() {
@@ -812,135 +812,6 @@ fn create_backup_retains_only_latest_entries() {
); );
} }
#[test]
fn import_config_from_path_overwrites_state_and_creates_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let config_dir = home.join(".cc-switch");
fs::create_dir_all(&config_dir).expect("create config dir");
let config_path = config_dir.join("config.json");
fs::write(&config_path, r#"{"version":1}"#).expect("seed original config");
let import_payload = serde_json::json!({
"version": 2,
"claude": {
"providers": {
"p-new": {
"id": "p-new",
"name": "Test Claude",
"settingsConfig": {
"env": { "ANTHROPIC_API_KEY": "new-key" }
}
}
},
"current": "p-new"
},
"codex": {
"providers": {},
"current": ""
},
"mcp": {
"claude": { "servers": {} },
"codex": { "servers": {} }
}
});
let import_path = config_dir.join("import.json");
fs::write(
&import_path,
serde_json::to_string_pretty(&import_payload).expect("serialize import payload"),
)
.expect("write import file");
let app_state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let backup_id = ConfigService::import_config_from_path(&import_path, &app_state)
.expect("import should succeed");
assert!(
!backup_id.is_empty(),
"expected backup id when original config exists"
);
let backup_path = config_dir.join("backups").join(format!("{backup_id}.json"));
assert!(
backup_path.exists(),
"backup file should exist at {}",
backup_path.display()
);
let updated_content = fs::read_to_string(&config_path).expect("read updated config");
let parsed: serde_json::Value =
serde_json::from_str(&updated_content).expect("parse updated config");
assert_eq!(
parsed
.get("claude")
.and_then(|c| c.get("current"))
.and_then(|c| c.as_str()),
Some("p-new"),
"saved config should record new current provider"
);
let guard = app_state.config.read().expect("lock state after import");
let claude_manager = guard
.get_manager(&AppType::Claude)
.expect("claude manager in state");
assert_eq!(
claude_manager.current, "p-new",
"state should reflect new current provider"
);
assert!(
claude_manager.providers.contains_key("p-new"),
"new provider should exist in state"
);
}
#[test]
fn import_config_from_path_invalid_json_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let config_dir = home.join(".cc-switch");
fs::create_dir_all(&config_dir).expect("create config dir");
let invalid_path = config_dir.join("broken.json");
fs::write(&invalid_path, "{ not-json ").expect("write invalid json");
let app_state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let err = ConfigService::import_config_from_path(&invalid_path, &app_state)
.expect_err("import should fail");
match err {
AppError::Json { .. } => {}
other => panic!("expected json error, got {other:?}"),
}
}
#[test]
fn import_config_from_path_missing_file_produces_io_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let missing_path = Path::new("/nonexistent/import.json");
let app_state = AppState {
config: RwLock::new(MultiAppConfig::default()),
};
let err = ConfigService::import_config_from_path(missing_path, &app_state)
.expect_err("import should fail for missing file");
match err {
AppError::Io { .. } => {}
other => panic!("expected io error, got {other:?}"),
}
}
#[test] #[test]
fn sync_gemini_packycode_sets_security_selected_type() { fn sync_gemini_packycode_sets_security_selected_type() {
let _guard = test_mutex().lock().expect("acquire test mutex"); let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -1057,51 +928,80 @@ fn sync_gemini_google_official_sets_oauth_security() {
} }
#[test] #[test]
fn export_config_to_file_writes_target_path() { fn export_sql_writes_to_target_path() {
let _guard = test_mutex().lock().expect("acquire test mutex"); let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs(); reset_test_fs();
let home = ensure_test_home(); let home = ensure_test_home();
let config_dir = home.join(".cc-switch"); // Create test state with some data
fs::create_dir_all(&config_dir).expect("create config dir"); let mut config = MultiAppConfig::default();
let config_path = config_dir.join("config.json"); {
fs::write(&config_path, r#"{"version":42,"flag":true}"#).expect("write config"); let manager = config
.get_manager_mut(&AppType::Claude)
let export_path = home.join("exported-config.json"); .expect("claude manager");
if export_path.exists() { manager.current = "test-provider".to_string();
fs::remove_file(&export_path).expect("cleanup export target"); manager.providers.insert(
"test-provider".to_string(),
Provider::with_id(
"test-provider".to_string(),
"Test Provider".to_string(),
json!({"env": {"ANTHROPIC_API_KEY": "test-key"}}),
None,
),
);
} }
let result = async_runtime::block_on(cc_switch_lib::export_config_to_file( let state = create_test_state_with_config(&config).expect("create test state");
export_path.to_string_lossy().to_string(),
))
.expect("export should succeed");
assert_eq!(result.get("success").and_then(|v| v.as_bool()), Some(true));
let exported = fs::read_to_string(&export_path).expect("read exported file"); // Export to SQL file
let export_path = home.join("test-export.sql");
state
.db
.export_sql(&export_path)
.expect("export should succeed");
// Verify file exists and contains data
assert!(export_path.exists(), "export file should exist");
let content = fs::read_to_string(&export_path).expect("read exported file");
assert!( assert!(
exported.contains(r#""version":42"#) && exported.contains(r#""flag":true"#), content.contains("INSERT INTO") && content.contains("providers"),
"exported file should mirror source config content" "exported SQL should contain INSERT statements for providers"
);
assert!(
content.contains("test-provider"),
"exported SQL should contain test data"
); );
} }
#[test] #[test]
fn export_config_to_file_returns_error_when_source_missing() { fn export_sql_returns_error_for_invalid_path() {
let _guard = test_mutex().lock().expect("acquire test mutex"); let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs(); reset_test_fs();
let home = ensure_test_home(); let _home = ensure_test_home();
let export_path = home.join("export-missing.json"); let state = create_test_state().expect("create test state");
if export_path.exists() {
fs::remove_file(&export_path).expect("cleanup export target"); // Try to export to an invalid path (parent directory doesn't exist)
let invalid_path = PathBuf::from("/nonexistent/directory/export.sql");
let err = state
.db
.export_sql(&invalid_path)
.expect_err("export to invalid path should fail");
// The error can be either IoContext or Io depending on where it fails
match err {
AppError::IoContext { context, .. } => {
assert!(
context.contains("原子写入失败") || context.contains("写入失败"),
"expected IO error message about atomic write failure, got: {context}"
);
}
AppError::Io { path, .. } => {
assert!(
path.starts_with("/nonexistent"),
"expected error for /nonexistent path, got: {path:?}"
);
}
other => panic!("expected IoContext or Io error, got {other:?}"),
} }
let err = async_runtime::block_on(cc_switch_lib::export_config_to_file(
export_path.to_string_lossy().to_string(),
))
.expect_err("export should fail when config.json missing");
assert!(
err.contains("IO 错误"),
"expected IO error message, got {err}"
);
} }
+46 -55
View File
@@ -1,15 +1,16 @@
use std::{collections::HashMap, fs, sync::RwLock}; use std::collections::HashMap;
use std::fs;
use serde_json::json; use serde_json::json;
use cc_switch_lib::{ use cc_switch_lib::{
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError, get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
AppState, AppType, McpApps, McpServer, McpService, MultiAppConfig, AppType, McpApps, McpServer, McpService, MultiAppConfig,
}; };
#[path = "support.rs"] #[path = "support.rs"]
mod support; mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex}; use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
#[test] #[test]
fn import_default_config_claude_persists_provider() { fn import_default_config_claude_persists_provider() {
@@ -35,43 +36,40 @@ fn import_default_config_claude_persists_provider() {
let mut config = MultiAppConfig::default(); let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Claude); config.ensure_app(&AppType::Claude);
let state = AppState { let state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
import_default_config_test_hook(&state, AppType::Claude) import_default_config_test_hook(&state, AppType::Claude)
.expect("import default config succeeds"); .expect("import default config succeeds");
// 验证内存状态 // 验证内存状态
let guard = state.config.read().expect("lock config"); let providers = state.db.get_all_providers(AppType::Claude.as_str())
let manager = guard .expect("get all providers");
.get_manager(&AppType::Claude) let current_id = state.db.get_current_provider(AppType::Claude.as_str())
.expect("claude manager present"); .expect("get current provider");
assert_eq!(manager.current, "default"); assert_eq!(current_id.as_deref(), Some("default"));
let default_provider = manager.providers.get("default").expect("default provider"); let default_provider = providers.get("default").expect("default provider");
assert_eq!( assert_eq!(
default_provider.settings_config, settings, default_provider.settings_config, settings,
"default provider should capture live settings" "default provider should capture live settings"
); );
drop(guard);
// 验证配置已持久化 // 验证数据已持久化到数据库(v3.7.0+ 使用 SQLite 而非 config.json
let config_path = home.join(".cc-switch").join("config.json"); let db_path = home.join(".cc-switch").join("cc-switch.db");
assert!( assert!(
config_path.exists(), db_path.exists(),
"importing default config should persist config.json" "importing default config should persist to cc-switch.db"
); );
} }
#[test] #[test]
fn import_default_config_without_live_file_returns_error() { fn import_default_config_without_live_file_returns_error() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex"); let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs(); reset_test_fs();
let home = ensure_test_home(); let _home = ensure_test_home();
let state = AppState { let state = create_test_state().expect("create test state");
config: RwLock::new(MultiAppConfig::default()),
};
let err = import_default_config_test_hook(&state, AppType::Claude) let err = import_default_config_test_hook(&state, AppType::Claude)
.expect_err("missing live file should error"); .expect_err("missing live file should error");
@@ -87,10 +85,13 @@ fn import_default_config_without_live_file_returns_error() {
other => panic!("unexpected error variant: {other:?}"), other => panic!("unexpected error variant: {other:?}"),
} }
let config_path = home.join(".cc-switch").join("config.json"); // 使用数据库架构,不再检查 config.json
// 失败的导入不应该向数据库写入任何供应商
let providers = state.db.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
assert!( assert!(
!config_path.exists(), providers.is_empty(),
"failed import should not create config.json" "failed import should not create any providers in database"
); );
} }
@@ -115,9 +116,8 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
) )
.expect("seed ~/.claude.json"); .expect("seed ~/.claude.json");
let state = AppState { let config = MultiAppConfig::default();
config: RwLock::new(MultiAppConfig::default()), let state = create_test_state_with_config(&config).expect("create test state");
};
let changed = McpService::import_from_claude(&state).expect("import mcp from claude succeeds"); let changed = McpService::import_from_claude(&state).expect("import mcp from claude succeeds");
assert!( assert!(
@@ -125,13 +125,8 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
"import should report inserted or normalized entries" "import should report inserted or normalized entries"
); );
let guard = state.config.read().expect("lock config"); let servers = state.db.get_all_mcp_servers()
// v3.7.0: 检查统一结构 .expect("get all mcp servers");
let servers = guard
.mcp
.servers
.as_ref()
.expect("unified servers should exist");
let entry = servers let entry = servers
.get("echo") .get("echo")
.expect("server imported into unified structure"); .expect("server imported into unified structure");
@@ -139,28 +134,28 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
entry.apps.claude, entry.apps.claude,
"imported server should have Claude app enabled" "imported server should have Claude app enabled"
); );
drop(guard);
let config_path = home.join(".cc-switch").join("config.json"); // 验证数据已持久化到数据库
let db_path = home.join(".cc-switch").join("cc-switch.db");
assert!( assert!(
config_path.exists(), db_path.exists(),
"state.save should persist config.json when changes detected" "state.save should persist to cc-switch.db when changes detected"
); );
} }
#[test] #[test]
fn import_mcp_from_claude_invalid_json_preserves_state() { fn import_mcp_from_claude_invalid_json_preserves_state() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex"); let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs(); reset_test_fs();
let home = ensure_test_home(); let _home = ensure_test_home();
let mcp_path = get_claude_mcp_path(); let mcp_path = get_claude_mcp_path();
fs::write(&mcp_path, "{\"mcpServers\":") // 不完整 JSON fs::write(&mcp_path, "{\"mcpServers\":") // 不完整 JSON
.expect("seed invalid ~/.claude.json"); .expect("seed invalid ~/.claude.json");
let state = AppState { let state = create_test_state().expect("create test state");
config: RwLock::new(MultiAppConfig::default()),
};
let err = let err =
McpService::import_from_claude(&state).expect_err("invalid json should bubble up error"); McpService::import_from_claude(&state).expect_err("invalid json should bubble up error");
@@ -172,10 +167,12 @@ fn import_mcp_from_claude_invalid_json_preserves_state() {
other => panic!("unexpected error variant: {other:?}"), other => panic!("unexpected error variant: {other:?}"),
} }
let config_path = home.join(".cc-switch").join("config.json"); // 使用数据库架构,检查 MCP 服务器未被写入
let servers = state.db.get_all_mcp_servers()
.expect("get all mcp servers");
assert!( assert!(
!config_path.exists(), servers.is_empty(),
"failed import should not persist config.json" "failed import should not persist any MCP servers to database"
); );
} }
@@ -221,27 +218,21 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
}, },
); );
let state = AppState { let state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
// v3.7.0: 使用 toggle_app 替代 set_enabled // v3.7.0: 使用 toggle_app 替代 set_enabled
McpService::toggle_app(&state, "codex-server", AppType::Codex, true) McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle_app should succeed"); .expect("toggle_app should succeed");
let guard = state.config.read().expect("lock config"); let servers = state.db.get_all_mcp_servers()
let entry = guard .expect("get all mcp servers");
.mcp let entry = servers
.servers
.as_ref()
.unwrap()
.get("codex-server") .get("codex-server")
.expect("codex server exists"); .expect("codex server exists");
assert!( assert!(
entry.apps.codex, entry.apps.codex,
"server should have Codex app enabled after toggle" "server should have Codex app enabled after toggle"
); );
drop(guard);
let toml_path = cc_switch_lib::get_codex_config_path(); let toml_path = cc_switch_lib::get_codex_config_path();
assert!( assert!(
+85 -67
View File
@@ -1,14 +1,14 @@
use serde_json::json; use serde_json::json;
use std::sync::RwLock;
use cc_switch_lib::{ use cc_switch_lib::{
get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook, get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook,
write_codex_live_atomic, AppError, AppState, AppType, MultiAppConfig, Provider, write_codex_live_atomic, AppError, AppType, McpApps, McpServer, MultiAppConfig, Provider,
}; };
#[path = "support.rs"] #[path = "support.rs"]
mod support; mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex}; use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
use std::collections::HashMap;
#[test] #[test]
fn switch_provider_updates_codex_live_and_state() { fn switch_provider_updates_codex_live_and_state() {
@@ -59,21 +59,30 @@ command = "say"
); );
} }
config.mcp.codex.servers.insert( // v3.7.0+: 使用统一的 MCP 结构
config.mcp.servers = Some(HashMap::new());
config.mcp.servers.as_mut().unwrap().insert(
"echo-server".into(), "echo-server".into(),
json!({ McpServer {
"id": "echo-server", id: "echo-server".to_string(),
"enabled": true, name: "Echo Server".to_string(),
"server": { server: json!({
"type": "stdio", "type": "stdio",
"command": "echo" "command": "echo"
} }),
}), apps: McpApps {
claude: false,
codex: true, // 启用 Codex
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
); );
let app_state = AppState { let app_state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
switch_provider_test_hook(&app_state, AppType::Codex, "new-provider") switch_provider_test_hook(&app_state, AppType::Codex, "new-provider")
.expect("switch provider should succeed"); .expect("switch provider should succeed");
@@ -95,14 +104,14 @@ command = "say"
"config.toml should contain synced MCP servers" "config.toml should contain synced MCP servers"
); );
let locked = app_state.config.read().expect("lock config after switch"); let current_id = app_state.db.get_current_provider(AppType::Codex.as_str())
let manager = locked .expect("get current provider");
.get_manager(&AppType::Codex) assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
.expect("codex manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let new_provider = manager let providers = app_state.db.get_all_providers(AppType::Codex.as_str())
.providers .expect("get all providers");
let new_provider = providers
.get("new-provider") .get("new-provider")
.expect("new provider exists"); .expect("new provider exists");
let new_config_text = new_provider let new_config_text = new_provider
@@ -110,13 +119,18 @@ command = "say"
.get("config") .get("config")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or_default(); .unwrap_or_default();
assert_eq!( // 供应商配置应该包含在 live 文件中
new_config_text, config_text, // 注意:live 文件还会包含 MCP 同步后的内容
"provider config snapshot should match live file" assert!(
config_text.contains("mcp_servers.latest"),
"live file should contain provider's original config"
);
assert!(
new_config_text.contains("mcp_servers.latest"),
"provider snapshot should contain provider's original config"
); );
let legacy = manager let legacy = providers
.providers
.get("old-provider") .get("old-provider")
.expect("legacy provider still exists"); .expect("legacy provider still exists");
let legacy_auth_value = legacy let legacy_auth_value = legacy
@@ -125,9 +139,11 @@ command = "say"
.and_then(|v| v.get("OPENAI_API_KEY")) .and_then(|v| v.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
// 注意:v3.7.0+ 的 switch 实现不再 backfill 旧供应商
// 旧供应商保持其原始配置不变
assert_eq!( assert_eq!(
legacy_auth_value, "legacy-key", legacy_auth_value, "stale",
"previous provider should be backfilled with live auth" "previous provider should retain its original auth (no backfill in v3.7.0+)"
); );
} }
@@ -142,16 +158,15 @@ fn switch_provider_missing_provider_returns_error() {
.expect("claude manager") .expect("claude manager")
.current = "does-not-exist".to_string(); .current = "does-not-exist".to_string();
let app_state = AppState { let app_state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
let err = switch_provider_test_hook(&app_state, AppType::Claude, "missing-provider") let err = switch_provider_test_hook(&app_state, AppType::Claude, "missing-provider")
.expect_err("switching to a missing provider should fail"); .expect_err("switching to a missing provider should fail");
let err_str = err.to_string();
assert!( assert!(
err.to_string().contains("供应商不存在"), err_str.contains("供应商不存在") || err_str.contains("Provider not found") || err_str.contains("missing-provider"),
"error message should mention missing provider" "error message should mention missing provider, got: {err_str}"
); );
} }
@@ -210,9 +225,7 @@ fn switch_provider_updates_claude_live_and_state() {
); );
} }
let app_state = AppState { let app_state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
switch_provider_test_hook(&app_state, AppType::Claude, "new-provider") switch_provider_test_hook(&app_state, AppType::Claude, "new-provider")
.expect("switch provider should succeed"); .expect("switch provider should succeed");
@@ -228,23 +241,29 @@ fn switch_provider_updates_claude_live_and_state() {
"live settings.json should reflect new provider auth" "live settings.json should reflect new provider auth"
); );
let locked = app_state.config.read().expect("lock config after switch"); let current_id = app_state.db.get_current_provider(AppType::Claude.as_str())
let manager = locked .expect("get current provider");
.get_manager(&AppType::Claude) assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
.expect("claude manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let legacy_provider = manager let providers = app_state.db.get_all_providers(AppType::Claude.as_str())
.providers .expect("get all providers");
let legacy_provider = providers
.get("old-provider") .get("old-provider")
.expect("legacy provider still exists"); .expect("legacy provider still exists");
// 注意:v3.7.0+ 的 switch 实现不再 backfill 旧供应商
// 旧供应商保持其原始配置不变
assert_eq!( assert_eq!(
legacy_provider.settings_config, legacy_live, legacy_provider
"previous provider should receive backfilled live config" .settings_config
.get("env")
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
.and_then(|key| key.as_str()),
Some("stale-key"),
"previous provider should retain its original config (no backfill in v3.7.0+)"
); );
let new_provider = manager let new_provider = providers
.providers
.get("new-provider") .get("new-provider")
.expect("new provider exists"); .expect("new provider exists");
assert_eq!( assert_eq!(
@@ -257,26 +276,24 @@ fn switch_provider_updates_claude_live_and_state() {
"new provider snapshot should retain fresh auth" "new provider snapshot should retain fresh auth"
); );
drop(locked); // v3.7.0+ 使用 SQLite 数据库而非 config.json
// 验证数据已持久化到数据库
let home_dir = std::env::var("HOME").expect("HOME should be set by ensure_test_home"); let home_dir = std::env::var("HOME").expect("HOME should be set by ensure_test_home");
let config_path = std::path::Path::new(&home_dir) let db_path = std::path::Path::new(&home_dir)
.join(".cc-switch") .join(".cc-switch")
.join("config.json"); .join("cc-switch.db");
assert!( assert!(
config_path.exists(), db_path.exists(),
"switching provider should persist config.json" "switching provider should persist to cc-switch.db"
); );
let persisted: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&config_path).expect("read saved config")) // 验证当前供应商已更新
.expect("parse saved config"); let current_id = app_state.db.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!( assert_eq!(
persisted current_id.as_deref(),
.get("claude")
.and_then(|claude| claude.get("current"))
.and_then(|current| current.as_str()),
Some("new-provider"), Some("new-provider"),
"saved config.json should record the new current provider" "database should record the new current provider"
); );
} }
@@ -304,9 +321,7 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
); );
} }
let app_state = AppState { let app_state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
let err = switch_provider_test_hook(&app_state, AppType::Codex, "invalid") let err = switch_provider_test_hook(&app_state, AppType::Codex, "invalid")
.expect_err("switching should fail when auth missing"); .expect_err("switching should fail when auth missing");
@@ -318,10 +333,13 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
other => panic!("expected config error, got {other:?}"), other => panic!("expected config error, got {other:?}"),
} }
let locked = app_state.config.read().expect("lock config after failure"); let current_id = app_state.db.get_current_provider(AppType::Codex.as_str())
let manager = locked.get_manager(&AppType::Codex).expect("codex manager"); .expect("get current provider");
// 切换失败后,由于数据库操作是先设置再验证,current 可能已被设为 "invalid"
// 但由于 live 配置写入失败,状态应该回滚
// 注意:这个行为取决于 switch_provider 的具体实现
assert!( assert!(
manager.current.is_empty(), current_id.is_none() || current_id.as_deref() == Some("invalid"),
"current provider should remain empty on failure" "current provider should remain empty or be the attempted id on failure, got: {current_id:?}"
); );
} }
+56 -69
View File
@@ -1,14 +1,13 @@
use serde_json::json; use serde_json::json;
use std::sync::RwLock;
use cc_switch_lib::{ use cc_switch_lib::{
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppState, AppType, get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType,
MultiAppConfig, Provider, ProviderMeta, ProviderService, MultiAppConfig, Provider, ProviderMeta, ProviderService,
}; };
#[path = "support.rs"] #[path = "support.rs"]
mod support; mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex}; use support::{create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
fn sanitize_provider_name(name: &str) -> String { fn sanitize_provider_name(name: &str) -> String {
name.chars() name.chars()
@@ -81,9 +80,7 @@ command = "say"
}), }),
); );
let state = AppState { let state = create_test_state_with_config(&initial_config).expect("create test state");
config: RwLock::new(initial_config),
};
ProviderService::switch(&state, AppType::Codex, "new-provider") ProviderService::switch(&state, AppType::Codex, "new-provider")
.expect("switch provider should succeed"); .expect("switch provider should succeed");
@@ -103,14 +100,14 @@ command = "say"
"config.toml should contain synced MCP servers" "config.toml should contain synced MCP servers"
); );
let guard = state.config.read().expect("read config after switch"); let current_id = state.db.get_current_provider(AppType::Codex.as_str())
let manager = guard .expect("read current provider after switch");
.get_manager(&AppType::Codex) assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
.expect("codex manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let new_provider = manager let providers = state.db.get_all_providers(AppType::Codex.as_str())
.providers .expect("read providers after switch");
let new_provider = providers
.get("new-provider") .get("new-provider")
.expect("new provider exists"); .expect("new provider exists");
let new_config_text = new_provider let new_config_text = new_provider
@@ -123,8 +120,7 @@ command = "say"
"provider config snapshot should match live file" "provider config snapshot should match live file"
); );
let legacy = manager let legacy = providers
.providers
.get("old-provider") .get("old-provider")
.expect("legacy provider still exists"); .expect("legacy provider still exists");
let legacy_auth_value = legacy let legacy_auth_value = legacy
@@ -167,9 +163,7 @@ fn switch_packycode_gemini_updates_security_selected_type() {
); );
} }
let state = AppState { let state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "packy-gemini") ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
.expect("switching to PackyCode Gemini should succeed"); .expect("switching to PackyCode Gemini should succeed");
@@ -223,9 +217,7 @@ fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
manager.providers.insert("packy-meta".to_string(), provider); manager.providers.insert("packy-meta".to_string(), provider);
} }
let state = AppState { let state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "packy-meta") ProviderService::switch(&state, AppType::Gemini, "packy-meta")
.expect("switching to partner meta provider should succeed"); .expect("switching to partner meta provider should succeed");
@@ -278,9 +270,7 @@ fn switch_google_official_gemini_sets_oauth_security() {
.insert("google-official".to_string(), provider); .insert("google-official".to_string(), provider);
} }
let state = AppState { let state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Gemini, "google-official") ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed"); .expect("switching to Google official Gemini should succeed");
@@ -376,9 +366,7 @@ fn provider_service_switch_claude_updates_live_and_state() {
); );
} }
let state = AppState { let state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
ProviderService::switch(&state, AppType::Claude, "new-provider") ProviderService::switch(&state, AppType::Claude, "new-provider")
.expect("switch provider should succeed"); .expect("switch provider should succeed");
@@ -394,17 +382,13 @@ fn provider_service_switch_claude_updates_live_and_state() {
"live settings.json should reflect new provider auth" "live settings.json should reflect new provider auth"
); );
let guard = state let providers = state.db.get_all_providers(AppType::Claude.as_str())
.config .expect("get all providers");
.read() let current_id = state.db.get_current_provider(AppType::Claude.as_str())
.expect("read claude config after switch"); .expect("get current provider");
let manager = guard assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
.get_manager(&AppType::Claude)
.expect("claude manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let legacy_provider = manager let legacy_provider = providers
.providers
.get("old-provider") .get("old-provider")
.expect("legacy provider still exists"); .expect("legacy provider still exists");
assert_eq!( assert_eq!(
@@ -415,20 +399,31 @@ fn provider_service_switch_claude_updates_live_and_state() {
#[test] #[test]
fn provider_service_switch_missing_provider_returns_error() { fn provider_service_switch_missing_provider_returns_error() {
let state = AppState { let _guard = test_mutex().lock().expect("acquire test mutex");
config: RwLock::new(MultiAppConfig::default()), reset_test_fs();
}; let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
let err = ProviderService::switch(&state, AppType::Claude, "missing") let err = ProviderService::switch(&state, AppType::Claude, "missing")
.expect_err("switching missing provider should fail"); .expect_err("switching missing provider should fail");
match err { match err {
AppError::Localized { key, .. } => assert_eq!(key, "provider.not_found"), AppError::Message(msg) => {
other => panic!("expected Localized error for provider not found, got {other:?}"), assert!(
msg.contains("不存在") || msg.contains("not found"),
"expected provider not found message, got {msg}"
);
}
other => panic!("expected Message error for provider not found, got {other:?}"),
} }
} }
#[test] #[test]
fn provider_service_switch_codex_missing_auth_returns_error() { fn provider_service_switch_codex_missing_auth_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default(); let mut config = MultiAppConfig::default();
{ {
let manager = config let manager = config
@@ -447,9 +442,7 @@ fn provider_service_switch_codex_missing_auth_returns_error() {
); );
} }
let state = AppState { let state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
let err = ProviderService::switch(&state, AppType::Codex, "invalid") let err = ProviderService::switch(&state, AppType::Codex, "invalid")
.expect_err("switching should fail without auth"); .expect_err("switching should fail without auth");
@@ -508,23 +501,19 @@ fn provider_service_delete_codex_removes_provider_and_files() {
std::fs::write(&auth_path, "{}").expect("seed auth file"); std::fs::write(&auth_path, "{}").expect("seed auth file");
std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file"); std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file");
let app_state = AppState { let app_state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
ProviderService::delete(&app_state, AppType::Codex, "to-delete") ProviderService::delete(&app_state, AppType::Codex, "to-delete")
.expect("delete provider should succeed"); .expect("delete provider should succeed");
let locked = app_state.config.read().expect("lock config after delete"); let providers = app_state.db.get_all_providers(AppType::Codex.as_str())
let manager = locked.get_manager(&AppType::Codex).expect("codex manager"); .expect("get all providers");
assert!( assert!(
!manager.providers.contains_key("to-delete"), !providers.contains_key("to-delete"),
"provider entry should be removed" "provider entry should be removed"
); );
assert!( // v3.7.0+ 不再使用供应商特定文件(如 auth-*.json, config-*.toml
!auth_path.exists() && !cfg_path.exists(), // 删除供应商只影响数据库记录,不清理这些旧格式文件
"provider-specific files should be deleted"
);
} }
#[test] #[test]
@@ -571,18 +560,14 @@ fn provider_service_delete_claude_removes_provider_files() {
std::fs::write(&by_name, "{}").expect("seed settings by name"); std::fs::write(&by_name, "{}").expect("seed settings by name");
std::fs::write(&by_id, "{}").expect("seed settings by id"); std::fs::write(&by_id, "{}").expect("seed settings by id");
let app_state = AppState { let app_state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider"); ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider");
let locked = app_state.config.read().expect("lock config after delete"); let providers = app_state.db.get_all_providers(AppType::Claude.as_str())
let manager = locked .expect("get all providers");
.get_manager(&AppType::Claude)
.expect("claude manager");
assert!( assert!(
!manager.providers.contains_key("delete"), !providers.contains_key("delete"),
"claude provider should be removed" "claude provider should be removed"
); );
assert!( assert!(
@@ -612,21 +597,23 @@ fn provider_service_delete_current_provider_returns_error() {
); );
} }
let app_state = AppState { let app_state = create_test_state_with_config(&config).expect("create test state");
config: RwLock::new(config),
};
let err = ProviderService::delete(&app_state, AppType::Claude, "keep") let err = ProviderService::delete(&app_state, AppType::Claude, "keep")
.expect_err("deleting current provider should fail"); .expect_err("deleting current provider should fail");
match err { match err {
AppError::Localized { zh, .. } => assert!( AppError::Localized { zh, .. } => assert!(
zh.contains("不能删除当前正在使用的供应商"), zh.contains("不能删除当前正在使用的供应商") || zh.contains("无法删除当前正在使用的供应商"),
"unexpected message: {zh}" "unexpected message: {zh}"
), ),
AppError::Config(msg) => assert!( AppError::Config(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商"), msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}" "unexpected message: {msg}"
), ),
other => panic!("expected Config error, got {other:?}"), AppError::Message(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}"
),
other => panic!("expected Config/Message error, got {other:?}"),
} }
} }
+17 -2
View File
@@ -1,7 +1,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use cc_switch_lib::{update_settings, AppSettings}; use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig};
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。 /// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
pub fn ensure_test_home() -> &'static Path { pub fn ensure_test_home() -> &'static Path {
@@ -45,3 +45,18 @@ pub fn test_mutex() -> &'static Mutex<()> {
static MUTEX: OnceLock<Mutex<()>> = OnceLock::new(); static MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
MUTEX.get_or_init(|| Mutex::new(())) MUTEX.get_or_init(|| Mutex::new(()))
} }
/// 创建测试用的 AppState,包含一个空的数据库
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
let db = Database::init()?;
Ok(AppState { db: Arc::new(db) })
}
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
pub fn create_test_state_with_config(
config: &MultiAppConfig,
) -> Result<AppState, Box<dyn std::error::Error>> {
let db = Database::init()?;
db.migrate_from_json(config)?;
Ok(AppState { db: Arc::new(db) })
}