mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
2d478876fa
* fix claude mcp path for custom config dir * fix claude mcp custom profile isolation * fix claude mcp override path edges * fix ci test isolation
558 lines
18 KiB
Rust
558 lines
18 KiB
Rust
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<PathBuf> {
|
||
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<PathBuf> {
|
||
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::<String>()
|
||
.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<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, AppError> {
|
||
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<T: Serialize>(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(),
|
||
}
|
||
}
|