diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index c66adad..5eea813 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -89,7 +89,7 @@ impl Default for MultiAppConfig { } impl MultiAppConfig { - /// 从文件加载配置(处理v1到v2的迁移) + /// 从文件加载配置(仅支持 v2 结构) pub fn load() -> Result { let config_path = get_app_config_path(); @@ -102,45 +102,33 @@ impl MultiAppConfig { let content = std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; - // 检查是否是旧版本格式(v1) - if let Ok(v1_config) = serde_json::from_str::(&content) { - log::info!("检测到v1配置,自动迁移到v2"); - - // 迁移到新格式 - let mut apps = HashMap::new(); - apps.insert("claude".to_string(), v1_config); - apps.insert("codex".to_string(), ProviderManager::default()); - - let config = Self { - version: 2, - apps, - mcp: McpRoot::default(), - }; - - // 迁移前备份旧版(v1)配置文件 - let backup_dir = get_app_config_dir(); - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let backup_path = backup_dir.join(format!("config.v1.backup.{}.json", ts)); - - match copy_file(&config_path, &backup_path) { - Ok(()) => log::info!( - "已备份旧版配置文件: {} -> {}", - config_path.display(), - backup_path.display() - ), - Err(e) => log::warn!("备份旧版配置文件失败: {}", e), - } - - // 保存迁移后的配置 - config.save()?; - return Ok(config); + // 先解析为 Value,以便严格判定是否为 v1 结构; + // 满足:顶层同时包含 providers(object) + current(string),且不包含 version/apps/mcp 关键键,即视为 v1 + let value: serde_json::Value = + serde_json::from_str(&content).map_err(|e| AppError::json(&config_path, e))?; + let is_v1 = value.as_object().map_or(false, |map| { + let has_providers = map + .get("providers") + .map(|v| v.is_object()) + .unwrap_or(false); + let has_current = map + .get("current") + .map(|v| v.is_string()) + .unwrap_or(false); + // v1 的充分必要条件:有 providers 和 current,且 apps 不存在(version/mcp 可能存在但不作为 v2 判据) + let has_apps = map.contains_key("apps"); + has_providers && has_current && !has_apps + }); + if is_v1 { + return Err(AppError::localized( + "config.unsupported_v1", + "检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch/config.json,将顶层结构调整为:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n", + "Detected legacy v1 config. Runtime auto-migration is no longer supported.\n\nSolutions:\n1. Install v3.2.x for one-time auto-migration\n2. Or manually edit ~/.cc-switch/config.json to adjust the top-level structure:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n", + )); } - // 尝试读取v2格式 - serde_json::from_str::(&content).map_err(|e| AppError::json(&config_path, e)) + // 解析 v2 结构 + serde_json::from_value::(value).map_err(|e| AppError::json(&config_path, e)) } /// 保存配置到文件 diff --git a/src-tauri/tests/app_config_load.rs b/src-tauri/tests/app_config_load.rs new file mode 100644 index 0000000..648b645 --- /dev/null +++ b/src-tauri/tests/app_config_load.rs @@ -0,0 +1,105 @@ +use std::fs; +use std::path::PathBuf; + +use cc_switch_lib::{AppError, MultiAppConfig}; + +mod support; +use support::{ensure_test_home, reset_test_fs, test_mutex}; + +fn cfg_path() -> PathBuf { + let home = std::env::var("HOME").expect("HOME should be set by ensure_test_home"); + PathBuf::from(home).join(".cc-switch").join("config.json") +} + +#[test] +fn load_v1_config_returns_error_and_does_not_write() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let path = cfg_path(); + fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir"); + + // 最小 v1 形状:providers + current,且不含 version/apps/mcp + let v1_json = r#"{"providers":{},"current":""}"#; + fs::write(&path, v1_json).expect("seed v1 json"); + let before = fs::read_to_string(&path).expect("read before"); + + let err = MultiAppConfig::load().expect_err("v1 should not be auto-migrated"); + match err { + AppError::Localized { key, .. } => assert_eq!(key, "config.unsupported_v1"), + other => panic!("expected Localized v1 error, got {other:?}"), + } + + // 文件不应有任何变化,且不应生成 .bak + let after = fs::read_to_string(&path).expect("read after"); + assert_eq!(before, after, "config.json should not be modified"); + let bak = home.join(".cc-switch").join("config.json.bak"); + assert!(!bak.exists(), ".bak should not be created on load error"); +} + +#[test] +fn load_v1_with_extra_version_still_treated_as_v1() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let path = cfg_path(); + std::fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir"); + + // 畸形:包含 providers + current + version,但没有 apps,应按 v1 处理 + let v1_like = r#"{"providers":{},"current":"","version":2}"#; + std::fs::write(&path, v1_like).expect("seed v1-like json"); + let before = std::fs::read_to_string(&path).expect("read before"); + + let err = MultiAppConfig::load().expect_err("v1-like should not be parsed as v2"); + match err { + AppError::Localized { key, .. } => assert_eq!(key, "config.unsupported_v1"), + other => panic!("expected Localized v1 error, got {other:?}"), + } + + let after = std::fs::read_to_string(&path).expect("read after"); + assert_eq!(before, after, "config.json should not be modified"); + let bak = home.join(".cc-switch").join("config.json.bak"); + assert!(!bak.exists(), ".bak should not be created on v1-like error"); +} + +#[test] +fn load_invalid_json_returns_parse_error_and_does_not_write() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let path = cfg_path(); + fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir"); + + fs::write(&path, "{not json").expect("seed invalid json"); + let before = fs::read_to_string(&path).expect("read before"); + + let err = MultiAppConfig::load().expect_err("invalid json should error"); + match err { + AppError::Json { .. } => {} + other => panic!("expected Json error, got {other:?}"), + } + + let after = fs::read_to_string(&path).expect("read after"); + assert_eq!(before, after, "config.json should remain unchanged"); + let bak = home.join(".cc-switch").join("config.json.bak"); + assert!(!bak.exists(), ".bak should not be created on parse error"); +} + +#[test] +fn load_valid_v2_config_succeeds() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + let path = cfg_path(); + fs::create_dir_all(path.parent().unwrap()).expect("create cfg dir"); + + // 使用默认结构序列化为 v2 + let default_cfg = MultiAppConfig::default(); + let json = serde_json::to_string_pretty(&default_cfg).expect("serialize default cfg"); + fs::write(&path, json).expect("write v2 json"); + + let loaded = MultiAppConfig::load().expect("v2 should load successfully"); + assert_eq!(loaded.version, 2); + assert!(loaded.get_manager(&cc_switch_lib::AppType::Claude).is_some()); + assert!(loaded.get_manager(&cc_switch_lib::AppType::Codex).is_some()); +}