Files
CC-Switch/src-tauri/src/config.rs
T
makoMakoGo 2d478876fa fix: align Claude MCP path for custom config dirs (#3431)
* fix claude mcp path for custom config dir

* fix claude mcp custom profile isolation

* fix claude mcp override path edges

* fix ci test isolation
2026-06-23 09:00:56 +08:00

558 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
}
}