use crate::app_config::{AppType, MultiAppConfig}; use crate::config::{ archive_file, get_app_config_dir, get_app_config_path, get_claude_config_dir, }; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::PathBuf; fn now_ts() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() } fn get_marker_path() -> PathBuf { get_app_config_dir().join("migrated.copies.v1") } fn sanitized_id(base: &str) -> String { crate::config::sanitize_provider_name(base) } fn next_unique_id(existing: &HashSet, base: &str) -> String { let base = sanitized_id(base); if !existing.contains(&base) { return base; } for i in 2..1000 { let candidate = format!("{}-{}", base, i); if !existing.contains(&candidate) { return candidate; } } format!("{}-dup", base) } fn scan_claude_copies() -> Vec<(String, PathBuf, Value)> { let mut items = Vec::new(); let dir = get_claude_config_dir(); if !dir.exists() { return items; } if let Ok(rd) = fs::read_dir(&dir) { for e in rd.flatten() { let p = e.path(); let fname = match p.file_name().and_then(|s| s.to_str()) { Some(s) => s, None => continue, }; if fname == "settings.json" || fname == "claude.json" { continue; } if !fname.starts_with("settings-") || !fname.ends_with(".json") { continue; } let name = fname.trim_start_matches("settings-").trim_end_matches(".json"); if let Ok(val) = crate::config::read_json_file::(&p) { items.push((name.to_string(), p, val)); } } } items } fn scan_codex_copies() -> Vec<(String, Option, Option, Value)> { let mut by_name: HashMap, Option)> = HashMap::new(); let dir = crate::codex_config::get_codex_config_dir(); if !dir.exists() { return Vec::new(); } if let Ok(rd) = fs::read_dir(&dir) { for e in rd.flatten() { let p = e.path(); let fname = match p.file_name().and_then(|s| s.to_str()) { Some(s) => s, None => continue, }; if fname.starts_with("auth-") && fname.ends_with(".json") { let name = fname.trim_start_matches("auth-").trim_end_matches(".json"); let entry = by_name.entry(name.to_string()).or_default(); entry.0 = Some(p); } else if fname.starts_with("config-") && fname.ends_with(".toml") { let name = fname.trim_start_matches("config-").trim_end_matches(".toml"); let entry = by_name.entry(name.to_string()).or_default(); entry.1 = Some(p); } } } let mut items = Vec::new(); for (name, (auth_path, config_path)) in by_name { if let Some(authp) = auth_path { if let Ok(auth) = crate::config::read_json_file::(&authp) { let config_str = if let Some(cfgp) = &config_path { fs::read_to_string(cfgp).unwrap_or_default() } else { String::new() }; // 校验 TOML(若非空) if !config_str.trim().is_empty() { if let Err(e) = toml::from_str::(&config_str) { log::warn!("跳过无效 Codex config-{}.toml: {}", name, e); } } let settings = serde_json::json!({ "auth": auth, "config": config_str, }); items.push((name, Some(authp), config_path, settings)); } } } items } pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result { // 如果已迁移过则跳过 let marker = get_marker_path(); if marker.exists() { return Ok(false); } let claude_items = scan_claude_copies(); let codex_items = scan_codex_copies(); if claude_items.is_empty() && codex_items.is_empty() { // 即便没有可迁移项,也写入标记避免每次扫描 fs::write(&marker, b"no-copies").map_err(|e| format!("写入迁移标记失败: {}", e))?; return Ok(false); } // 备份旧的 config.json let ts = now_ts(); let app_cfg_path = get_app_config_path(); if app_cfg_path.exists() { let _ = archive_file(ts, "cc-switch", &app_cfg_path); } // 合并:Claude config.ensure_app(&AppType::Claude); let manager = config.get_manager_mut(&AppType::Claude).unwrap(); let mut ids: HashSet = manager.providers.keys().cloned().collect(); for (name, path, value) in claude_items.iter() { if let Some((id, prov)) = manager .providers .iter_mut() .find(|(_, p)| p.name == *name) { // 重名:覆盖为副本内容 log::info!("覆盖 Claude 供应商 '{}' 来自 {}", name, path.display()); prov.settings_config = value.clone(); } else { // 新增 let id = next_unique_id(&ids, name); ids.insert(id.clone()); let provider = crate::provider::Provider::with_id( id, name.clone(), value.clone(), None, ); manager.providers.insert(provider.id.clone(), provider); } } // 合并:Codex config.ensure_app(&AppType::Codex); let manager = config.get_manager_mut(&AppType::Codex).unwrap(); let mut ids: HashSet = manager.providers.keys().cloned().collect(); for (name, authp, cfgp, value) in codex_items.iter() { if let Some((_id, prov)) = manager .providers .iter_mut() .find(|(_, p)| p.name == *name) { log::info!("覆盖 Codex 供应商 '{}' 来自 {:?} / {:?}", name, authp, cfgp); prov.settings_config = value.clone(); } else { let id = next_unique_id(&ids, name); ids.insert(id.clone()); let provider = crate::provider::Provider::with_id( id, name.clone(), value.clone(), None, ); manager.providers.insert(provider.id.clone(), provider); } } // 归档副本文件 for (_, p, _) in claude_items.into_iter() { let _ = archive_file(ts, "claude", &p); } for (_, ap, cp, _) in codex_items.into_iter() { if let Some(ap) = ap { let _ = archive_file(ts, "codex", &ap); } if let Some(cp) = cp { let _ = archive_file(ts, "codex", &cp); } } // 标记完成 fs::write(&marker, b"done").map_err(|e| format!("写入迁移标记失败: {}", e))?; Ok(true) }