use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::fs; use std::io::Write; use std::path::{Component, Path, PathBuf}; use crate::error::AppError; /// 获取用户主目录,带回退和日志 /// /// ## Windows 注意事项 /// /// - `dirs::home_dir()` 在 Windows 上使用 `SHGetKnownFolderPath(FOLDERID_Profile)`, /// 返回的是真实用户目录(类似 `C:\\Users\\Alice`),与 v3.10.2 行为一致。 /// - 不要直接使用 `HOME` 环境变量:它可能由 Git/Cygwin/MSYS 等第三方工具注入, /// 且不一定等于用户目录,可能导致 `.cc-switch/cc-switch.db` 路径变化,从而“看起来像数据丢失”。 /// /// ## 测试隔离 /// /// 为了让 Windows CI/本地测试能稳定隔离真实用户数据,可通过 `CC_SWITCH_TEST_HOME` /// 显式覆盖 home dir(仅用于测试/调试场景)。 pub fn get_home_dir() -> PathBuf { if let Ok(home) = std::env::var("CC_SWITCH_TEST_HOME") { let trimmed = home.trim(); if !trimmed.is_empty() { return PathBuf::from(trimmed); } } dirs::home_dir().unwrap_or_else(|| { log::warn!("无法获取用户主目录,回退到当前目录"); PathBuf::from(".") }) } /// 获取 Claude Code 配置目录路径 pub fn get_claude_config_dir() -> PathBuf { if let Some(custom) = crate::settings::get_claude_override_dir() { return custom; } get_home_dir().join(".claude") } /// 默认 Claude MCP 配置文件路径 (~/.claude.json) pub fn get_default_claude_mcp_path() -> PathBuf { get_home_dir().join(".claude.json") } fn normalize_path_lexically(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { match component { Component::CurDir => {} Component::ParentDir => { if !normalized.pop() { normalized.push(component.as_os_str()); } } Component::Normal(part) => normalized.push(part), Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()), } } normalized } fn comparable_path_key(path: &Path) -> String { let mut key = normalize_path_lexically(path).to_string_lossy().to_string(); #[cfg(windows)] { key = key.replace('\\', "/"); } while key.len() > 1 && key.ends_with('/') { key.pop(); } #[cfg(windows)] { key.make_ascii_lowercase(); } key } fn path_eq_lexical(left: &Path, right: &Path) -> bool { comparable_path_key(left) == comparable_path_key(right) } #[cfg(windows)] fn derive_wsl_default_mcp_path(dir: &Path) -> Option { use std::path::Prefix; let normalized = normalize_path_lexically(dir); let mut components = normalized.components(); let prefix = match components.next()? { Component::Prefix(prefix) => prefix, _ => return None, }; let server = match prefix.kind() { Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => server.to_string_lossy(), _ => return None, }; if !server.eq_ignore_ascii_case("wsl$") && !server.eq_ignore_ascii_case("wsl.localhost") { return None; } let mut parts = Vec::new(); for component in components { match component { Component::RootDir | Component::CurDir => {} Component::Normal(part) => parts.push(part.to_string_lossy().to_string()), Component::ParentDir | Component::Prefix(_) => return None, } } let is_wsl_home_default = parts.len() == 3 && parts[0] == "home" && !parts[1].is_empty() && parts[2] == ".claude"; let is_wsl_root_default = parts.len() == 2 && parts[0] == "root" && parts[1] == ".claude"; if is_wsl_home_default || is_wsl_root_default { return normalized .parent() .map(|parent| parent.join(".claude.json")); } None } fn default_mcp_path_for_config_dir(dir: &Path) -> Option { let default_config_dir = get_home_dir().join(".claude"); if path_eq_lexical(dir, &default_config_dir) { return Some(get_default_claude_mcp_path()); } #[cfg(windows)] { if let Some(path) = derive_wsl_default_mcp_path(dir) { return Some(path); } } None } fn derive_mcp_path_from_override(dir: &Path) -> PathBuf { dir.join(".claude.json") } /// 获取 Claude MCP 配置文件路径 pub fn get_claude_mcp_path() -> PathBuf { if let Some(custom_dir) = crate::settings::get_claude_override_dir() { if let Some(path) = default_mcp_path_for_config_dir(&custom_dir) { return path; } return derive_mcp_path_from_override(&custom_dir); } get_default_claude_mcp_path() } /// 获取 Claude Code 主配置文件路径 pub fn get_claude_settings_path() -> PathBuf { let dir = get_claude_config_dir(); let settings = dir.join("settings.json"); if settings.exists() { return settings; } // 兼容旧版命名:若存在旧文件则继续使用 let legacy = dir.join("claude.json"); if legacy.exists() { return legacy; } // 默认新建:回落到标准文件名 settings.json(不再生成 claude.json) settings } /// 获取应用配置目录路径 (~/.cc-switch) pub fn get_app_config_dir() -> PathBuf { if let Some(custom) = crate::app_store::get_app_config_dir_override() { return custom; } let default_dir = get_home_dir().join(".cc-switch"); // 兼容 v3.10.3:当用户环境存在 `HOME` 且与真实用户目录不同, // v3.10.3 可能在 `HOME/.cc-switch/` 下创建/使用了数据库。 // 这里仅在“默认位置没有数据库”时回退到旧位置,避免再次出现“供应商消失”问题, // 同时也避免新安装因为 `HOME` 被设置而写入非预期路径。 #[cfg(windows)] { let default_db = default_dir.join("cc-switch.db"); if !default_db.exists() { if let Ok(home_env) = std::env::var("HOME") { let trimmed = home_env.trim(); if !trimmed.is_empty() { let legacy_dir = PathBuf::from(trimmed).join(".cc-switch"); if legacy_dir.join("cc-switch.db").exists() { log::info!( "Detected v3.10.3 legacy database at {}, using it instead of {}", legacy_dir.display(), default_dir.display() ); return legacy_dir; } } } } } default_dir } /// 获取应用配置文件路径 pub fn get_app_config_path() -> PathBuf { get_app_config_dir().join("config.json") } /// 清理供应商名称,确保文件名安全 #[allow(dead_code)] pub fn sanitize_provider_name(name: &str) -> String { name.chars() .map(|c| match c { '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-', _ => c, }) .collect::() .to_lowercase() } /// 获取供应商配置文件路径 #[allow(dead_code)] pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf { let base_name = provider_name .map(sanitize_provider_name) .unwrap_or_else(|| sanitize_provider_name(provider_id)); get_claude_config_dir().join(format!("settings-{base_name}.json")) } /// 读取 JSON 配置文件 pub fn read_json_file Deserialize<'a>>(path: &Path) -> Result { if !path.exists() { return Err(AppError::Config(format!("文件不存在: {}", path.display()))); } let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?; serde_json::from_str(&content).map_err(|e| AppError::json(path, e)) } /// 递归排序 JSON 对象的键(按字母顺序),确保序列化输出是确定性的 fn sort_json_keys(value: &Value) -> Value { match value { Value::Object(map) => { let mut sorted_map = Map::new(); let mut keys: Vec<_> = map.keys().collect(); keys.sort(); for key in keys { sorted_map.insert(key.clone(), sort_json_keys(&map[key])); } Value::Object(sorted_map) } Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()), other => other.clone(), } } /// 写入 JSON 配置文件(键按字母排序,确保确定性输出) pub fn write_json_file(path: &Path, data: &T) -> Result<(), AppError> { // 确保目录存在 if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } let value = serde_json::to_value(data).map_err(|e| AppError::JsonSerialize { source: e })?; let sorted_value = sort_json_keys(&value); let json = serde_json::to_string_pretty(&sorted_value) .map_err(|e| AppError::JsonSerialize { source: e })?; atomic_write(path, json.as_bytes()) } /// 原子写入文本文件(用于 TOML/纯文本) pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } atomic_write(path, data.as_bytes()) } /// 原子写入:写入临时文件后 rename 替换,避免半写状态 pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } let parent = path .parent() .ok_or_else(|| AppError::Config("无效的路径".to_string()))?; let mut tmp = parent.to_path_buf(); let file_name = path .file_name() .ok_or_else(|| AppError::Config("无效的文件名".to_string()))? .to_string_lossy() .to_string(); let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos(); tmp.push(format!("{file_name}.tmp.{ts}")); { let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?; f.write_all(data).map_err(|e| AppError::io(&tmp, e))?; f.flush().map_err(|e| AppError::io(&tmp, e))?; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Ok(meta) = fs::metadata(path) { let perm = meta.permissions().mode(); let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm)); } } #[cfg(windows)] { // Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性) if path.exists() { let _ = fs::remove_file(path); } fs::rename(&tmp, path).map_err(|e| AppError::IoContext { context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()), source: e, })?; } #[cfg(not(windows))] { fs::rename(&tmp, path).map_err(|e| AppError::IoContext { context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()), source: e, })?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn derive_mcp_path_from_override_uses_config_dir_for_custom_path() { let override_dir = PathBuf::from("/tmp/profile/.claude"); let derived = derive_mcp_path_from_override(&override_dir); assert_eq!(derived, PathBuf::from("/tmp/profile/.claude/.claude.json")); } #[test] fn derive_mcp_path_from_override_uses_config_dir_for_non_hidden_folder() { let override_dir = PathBuf::from("/data/claude-config"); let derived = derive_mcp_path_from_override(&override_dir); assert_eq!(derived, PathBuf::from("/data/claude-config/.claude.json")); } #[test] fn derive_mcp_path_from_override_supports_relative_rootless_dir() { let override_dir = PathBuf::from("claude"); let derived = derive_mcp_path_from_override(&override_dir); assert_eq!(derived, PathBuf::from("claude/.claude.json")); } #[test] fn derive_mcp_path_from_root_like_dir_uses_root_file() { let override_dir = PathBuf::from("/"); let derived = derive_mcp_path_from_override(&override_dir); assert_eq!(derived, PathBuf::from("/.claude.json")); } #[test] fn derive_mcp_path_from_override_preserves_leading_parent_dirs() { let override_dir = PathBuf::from("../../profiles/work/.claude"); let derived = derive_mcp_path_from_override(&override_dir); assert_eq!(derived, override_dir.join(".claude.json")); } #[cfg(windows)] #[test] fn wsl_unc_home_default_uses_split_mcp_path() { let override_dir = PathBuf::from(r"\\wsl$\Ubuntu\home\travis\.claude"); let derived = default_mcp_path_for_config_dir(&override_dir) .expect("WSL home default should use split MCP path"); assert_eq!( derived, PathBuf::from(r"\\wsl$\Ubuntu\home\travis\.claude.json") ); } #[cfg(windows)] #[test] fn wsl_unc_root_default_uses_split_mcp_path() { let override_dir = PathBuf::from(r"\\wsl.localhost\Ubuntu\root\.claude"); let derived = default_mcp_path_for_config_dir(&override_dir) .expect("WSL root default should use split MCP path"); assert_eq!( derived, PathBuf::from(r"\\wsl.localhost\Ubuntu\root\.claude.json") ); } #[cfg(windows)] #[test] fn wsl_unc_custom_dir_uses_nested_mcp_path() { let override_dir = PathBuf::from(r"\\wsl$\Ubuntu\opt\claude\.claude"); assert!(default_mcp_path_for_config_dir(&override_dir).is_none()); assert_eq!( derive_mcp_path_from_override(&override_dir), PathBuf::from(r"\\wsl$\Ubuntu\opt\claude\.claude\.claude.json") ); } #[test] fn sort_json_keys_sorts_top_level_object() { let input = serde_json::json!({ "z": 1, "a": 2, "m": 3, }); let sorted = sort_json_keys(&input); let serialized = serde_json::to_string(&sorted).unwrap(); assert_eq!(serialized, r#"{"a":2,"m":3,"z":1}"#); } #[test] fn sort_json_keys_recurses_into_nested_objects() { let input = serde_json::json!({ "outer_b": {"z": 1, "a": 2}, "outer_a": {"y": 3, "b": 4}, }); let sorted = sort_json_keys(&input); let serialized = serde_json::to_string(&sorted).unwrap(); assert_eq!( serialized, r#"{"outer_a":{"b":4,"y":3},"outer_b":{"a":2,"z":1}}"# ); } #[test] fn sort_json_keys_preserves_array_order() { let input = serde_json::json!([3, 1, 2]); let sorted = sort_json_keys(&input); let serialized = serde_json::to_string(&sorted).unwrap(); assert_eq!(serialized, "[3,1,2]"); } #[test] fn sort_json_keys_sorts_objects_inside_arrays_but_keeps_array_order() { let input = serde_json::json!([ {"z": 1, "a": 2}, {"y": 3, "b": 4}, ]); let sorted = sort_json_keys(&input); let serialized = serde_json::to_string(&sorted).unwrap(); assert_eq!(serialized, r#"[{"a":2,"z":1},{"b":4,"y":3}]"#); } #[test] fn sort_json_keys_passes_through_primitives() { let cases = vec![ serde_json::json!("hello"), serde_json::json!(42), serde_json::json!(3.5), serde_json::json!(true), serde_json::json!(null), ]; for value in cases { let sorted = sort_json_keys(&value); assert_eq!(sorted, value); } } #[test] fn sort_json_keys_handles_empty_collections() { let empty_obj = serde_json::json!({}); assert_eq!( serde_json::to_string(&sort_json_keys(&empty_obj)).unwrap(), "{}" ); let empty_arr = serde_json::json!([]); assert_eq!( serde_json::to_string(&sort_json_keys(&empty_arr)).unwrap(), "[]" ); } #[test] fn sort_json_keys_produces_identical_output_for_different_insertion_orders() { // 核心保证:同一逻辑配置无论键的插入顺序如何,写出的字节序列必须一致。 let mut a = Map::new(); a.insert("env".to_string(), serde_json::json!({"PATH": "/usr/bin"})); a.insert("model".to_string(), serde_json::json!("claude-sonnet-4-5")); a.insert("permissions".to_string(), serde_json::json!({"allow": []})); let mut b = Map::new(); b.insert("permissions".to_string(), serde_json::json!({"allow": []})); b.insert("model".to_string(), serde_json::json!("claude-sonnet-4-5")); b.insert("env".to_string(), serde_json::json!({"PATH": "/usr/bin"})); let sorted_a = sort_json_keys(&Value::Object(a)); let sorted_b = sort_json_keys(&Value::Object(b)); assert_eq!( serde_json::to_string(&sorted_a).unwrap(), serde_json::to_string(&sorted_b).unwrap(), ); } } /// 复制文件 pub fn copy_file(from: &Path, to: &Path) -> Result<(), AppError> { fs::copy(from, to).map_err(|e| AppError::IoContext { context: format!("复制文件失败 ({} -> {})", from.display(), to.display()), source: e, })?; Ok(()) } /// 删除文件 pub fn delete_file(path: &Path) -> Result<(), AppError> { if path.exists() { fs::remove_file(path).map_err(|e| AppError::io(path, e))?; } Ok(()) } /// 检查 Claude Code 配置状态 #[derive(Serialize, Deserialize)] pub struct ConfigStatus { pub exists: bool, pub path: String, } /// 获取 Claude Code 配置状态 pub fn get_claude_config_status() -> ConfigStatus { let path = get_claude_settings_path(); ConfigStatus { exists: path.exists(), path: path.to_string_lossy().to_string(), } }