refactor(config): remove v1 auto-migration and improve error handling
BREAKING CHANGE: Runtime auto-migration from v1 to v2 config format has been removed. Changes: - Remove automatic v1→v2 migration logic from MultiAppConfig::load() - Improve v1 detection using structural analysis (checks for 'apps' key absence) - Return clear error with migration instructions when v1 config is detected - Add comprehensive tests for config loading edge cases - Fix false positive detection when v1 config contains 'version' or 'mcp' fields Migration path for users: 1. Install v3.2.x to perform one-time auto-migration, OR 2. Manually edit ~/.cc-switch/config.json to v2 format Rationale: - Separates concerns: load() should be read-only, not perform side effects - Fail-fast principle: unsupported formats should error immediately - Simplifies code maintenance by removing migration logic from hot path Tests added: - load_v1_config_returns_error_and_does_not_write - load_v1_with_extra_version_still_treated_as_v1 - load_invalid_json_returns_parse_error_and_does_not_write - load_valid_v2_config_succeeds
This commit is contained in:
@@ -89,7 +89,7 @@ impl Default for MultiAppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MultiAppConfig {
|
impl MultiAppConfig {
|
||||||
/// 从文件加载配置(处理v1到v2的迁移)
|
/// 从文件加载配置(仅支持 v2 结构)
|
||||||
pub fn load() -> Result<Self, AppError> {
|
pub fn load() -> Result<Self, AppError> {
|
||||||
let config_path = get_app_config_path();
|
let config_path = get_app_config_path();
|
||||||
|
|
||||||
@@ -102,45 +102,33 @@ impl MultiAppConfig {
|
|||||||
let content =
|
let content =
|
||||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
|
||||||
// 检查是否是旧版本格式(v1)
|
// 先解析为 Value,以便严格判定是否为 v1 结构;
|
||||||
if let Ok(v1_config) = serde_json::from_str::<ProviderManager>(&content) {
|
// 满足:顶层同时包含 providers(object) + current(string),且不包含 version/apps/mcp 关键键,即视为 v1
|
||||||
log::info!("检测到v1配置,自动迁移到v2");
|
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 mut apps = HashMap::new();
|
let has_providers = map
|
||||||
apps.insert("claude".to_string(), v1_config);
|
.get("providers")
|
||||||
apps.insert("codex".to_string(), ProviderManager::default());
|
.map(|v| v.is_object())
|
||||||
|
.unwrap_or(false);
|
||||||
let config = Self {
|
let has_current = map
|
||||||
version: 2,
|
.get("current")
|
||||||
apps,
|
.map(|v| v.is_string())
|
||||||
mcp: McpRoot::default(),
|
.unwrap_or(false);
|
||||||
};
|
// v1 的充分必要条件:有 providers 和 current,且 apps 不存在(version/mcp 可能存在但不作为 v2 判据)
|
||||||
|
let has_apps = map.contains_key("apps");
|
||||||
// 迁移前备份旧版(v1)配置文件
|
has_providers && has_current && !has_apps
|
||||||
let backup_dir = get_app_config_dir();
|
});
|
||||||
let ts = std::time::SystemTime::now()
|
if is_v1 {
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
return Err(AppError::localized(
|
||||||
.unwrap_or_default()
|
"config.unsupported_v1",
|
||||||
.as_secs();
|
"检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch/config.json,将顶层结构调整为:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
|
||||||
let backup_path = backup_dir.join(format!("config.v1.backup.{}.json", ts));
|
"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",
|
||||||
|
));
|
||||||
match copy_file(&config_path, &backup_path) {
|
|
||||||
Ok(()) => log::info!(
|
|
||||||
"已备份旧版配置文件: {} -> {}",
|
|
||||||
config_path.display(),
|
|
||||||
backup_path.display()
|
|
||||||
),
|
|
||||||
Err(e) => log::warn!("备份旧版配置文件失败: {}", e),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存迁移后的配置
|
// 解析 v2 结构
|
||||||
config.save()?;
|
serde_json::from_value::<Self>(value).map_err(|e| AppError::json(&config_path, e))
|
||||||
return Ok(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试读取v2格式
|
|
||||||
serde_json::from_str::<Self>(&content).map_err(|e| AppError::json(&config_path, e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存配置到文件
|
/// 保存配置到文件
|
||||||
|
|||||||
105
src-tauri/tests/app_config_load.rs
Normal file
105
src-tauri/tests/app_config_load.rs
Normal file
@@ -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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user