From 1af20b5f8f5fdbf2fcc3c817c8f1e6a1e95a06ea Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 24 Nov 2025 23:35:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20dry-run=20mode=20for=20JSON?= =?UTF-8?q?=E2=86=92SQLite=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core changes: - Extract transaction logic into reusable migrate_from_json_tx() helper - Add migrate_from_json_dry_run() for in-memory validation without disk writes - Implement three-state migration mode enum (Disabled/DryRun/Enabled) - Support CC_SWITCH_ENABLE_JSON_DB_MIGRATION=dryrun for safe testing Code quality improvements: - Remove redundant migration_needed variable - Unify all log messages to English - Use info level for user-initiated operations instead of warn - Add explicit drop(tx) in dry-run to clarify intent Testing: - Add dry_run_does_not_write_to_disk test - Add dry_run_validates_schema_compatibility test with real data - All 6 database tests passing, zero clippy warnings This enables users to safely validate migration compatibility before committing to the database, catching schema mismatches early. --- src-tauri/src/database.rs | 114 +++++++++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 80 +++++++++++++++++--------- 2 files changed, 166 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index b41971cd3..fe9dda589 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -721,6 +721,34 @@ impl Database { .transaction() .map_err(|e| AppError::Database(e.to_string()))?; + Self::migrate_from_json_tx(&tx, config)?; + + tx.commit() + .map_err(|e| AppError::Database(format!("Commit migration failed: {e}")))?; + Ok(()) + } + + /// Run migration dry-run in memory for pre-deployment validation (no disk writes) + pub fn migrate_from_json_dry_run(config: &MultiAppConfig) -> Result<(), AppError> { + let mut conn = + Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?; + Self::create_tables_on_conn(&conn)?; + Self::apply_schema_migrations_on_conn(&conn)?; + + let tx = conn + .transaction() + .map_err(|e| AppError::Database(e.to_string()))?; + Self::migrate_from_json_tx(&tx, config)?; + + // Explicitly drop transaction without committing (in-memory DB discarded anyway) + drop(tx); + Ok(()) + } + + fn migrate_from_json_tx( + tx: &rusqlite::Transaction<'_>, + config: &MultiAppConfig, + ) -> Result<(), AppError> { // 1. 迁移 Providers for (app_key, manager) in &config.apps { let app_type = app_key; // "claude", "codex", "gemini" @@ -862,8 +890,6 @@ impl Database { .map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?; } - tx.commit() - .map_err(|e| AppError::Database(format!("Commit migration failed: {e}")))?; Ok(()) } @@ -1659,4 +1685,88 @@ mod tests { Some("1") ); } + + #[test] + fn dry_run_does_not_write_to_disk() { + use crate::app_config::MultiAppConfig; + use crate::provider::ProviderManager; + use std::collections::HashMap; + + // Create minimal valid config for migration + let mut apps = HashMap::new(); + apps.insert("claude".to_string(), ProviderManager::default()); + + let config = MultiAppConfig { + version: 2, + apps, + mcp: Default::default(), + prompts: Default::default(), + skills: Default::default(), + common_config_snippets: Default::default(), + claude_common_config_snippet: None, + }; + + // Dry-run should succeed without any file I/O errors + let result = Database::migrate_from_json_dry_run(&config); + assert!( + result.is_ok(), + "Dry-run should succeed with valid config: {result:?}" + ); + + // Verify dry-run can detect schema errors early + // (This would fail if migrate_from_json_tx had incompatible SQL) + } + + #[test] + fn dry_run_validates_schema_compatibility() { + use crate::app_config::MultiAppConfig; + use crate::provider::{Provider, ProviderManager}; + use indexmap::IndexMap; + use serde_json::json; + + // Create config with actual provider data + let mut providers = IndexMap::new(); + providers.insert( + "test-provider".to_string(), + Provider { + id: "test-provider".to_string(), + name: "Test Provider".to_string(), + settings_config: json!({ + "anthropicApiKey": "sk-test-123", + }), + website_url: None, + category: None, + created_at: Some(1234567890), + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + }, + ); + + let mut manager = ProviderManager::default(); + manager.providers = providers; + manager.current = "test-provider".to_string(); + + let mut apps = HashMap::new(); + apps.insert("claude".to_string(), manager); + + let config = MultiAppConfig { + version: 2, + apps, + mcp: Default::default(), + prompts: Default::default(), + skills: Default::default(), + common_config_snippets: Default::default(), + claude_common_config_snippet: None, + }; + + // Dry-run should validate the full migration path + let result = Database::migrate_from_json_dry_run(&config); + assert!( + result.is_ok(), + "Dry-run should succeed with provider data: {result:?}" + ); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 145e91702..5516c32e7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -113,14 +113,22 @@ const TRAY_SECTIONS: [TrayAppSection; 3] = [ }, ]; -/// 读取类似布尔的环境变量(1/true/yes/on 视为开启) -fn env_flag_enabled(key: &str) -> bool { - match std::env::var(key) { - Ok(val) => matches!( - val.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ), - Err(_) => false, +#[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, } } @@ -553,9 +561,10 @@ pub fn run() { let db_path = app_config_dir.join("cc-switch.db"); let json_path = app_config_dir.join("config.json"); - // 检查是否需要从 config.json 迁移到 SQLite,功能尚未正式发布,默认关闭 - let migration_opt_in = env_flag_enabled("CC_SWITCH_ENABLE_JSON_DB_MIGRATION"); - let migration_needed = !db_path.exists() && json_path.exists() && migration_opt_in; + // Check if config.json→SQLite migration needed (feature gated, disabled by default) + 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() { Ok(db) => Arc::new(db), @@ -567,23 +576,42 @@ pub fn run() { } }; - if !migration_opt_in && !db_path.exists() && json_path.exists() { - log::warn!( - "检测到 config.json,但 JSON→数据库迁移功能尚未正式发布,默认跳过。设置 CC_SWITCH_ENABLE_JSON_DB_MIGRATION=1 后再尝试。" - ); - } else if migration_needed { - log::info!("Starting migration from config.json to SQLite (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 - // let _ = std::fs::rename(&json_path, json_path.with_extension("json.bak")); + if !has_db && has_json { + match migration_mode { + JsonMigrationMode::Disabled => { + log::warn!( + "Detected config.json but migration is disabled by default. \ + Set CC_SWITCH_ENABLE_JSON_DB_MIGRATION=1 to migrate, or =dryrun to validate first." + ); + } + JsonMigrationMode::DryRun => { + 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}"), } }