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:
Jason
2025-11-06 09:18:21 +08:00
parent d6fa0060fb
commit db80e96786
2 changed files with 131 additions and 38 deletions

View File

@@ -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),
}
// 保存迁移后的配置
config.save()?;
return Ok(config);
} }
// 尝试读取v2格式 // 解析 v2 结构
serde_json::from_str::<Self>(&content).map_err(|e| AppError::json(&config_path, e)) serde_json::from_value::<Self>(value).map_err(|e| AppError::json(&config_path, e))
} }
/// 保存配置到文件 /// 保存配置到文件

View 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());
}